├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md └── contributing.md ├── .gitignore ├── .watchmanconfig ├── README.md ├── config ├── jest.config.js └── setupTests.ts ├── docs ├── .nojekyll ├── CNAME ├── README.md ├── _coverpage.md └── index.html ├── license.md ├── package.json ├── public ├── abort-example-1.gif ├── abort-example-2.gif ├── apte-logo.png ├── ava-logo.png ├── dog.png ├── microsoft-logo.png ├── mozilla.png └── watch-youtube-video.png ├── src ├── ErrorBoundary.ts ├── FetchContext.ts ├── Provider.tsx ├── __tests__ │ ├── Provider.test.tsx │ ├── doFetchArgs.test.tsx │ ├── tsconfig.json │ ├── useFetch-server.test.tsx │ ├── useFetch.test.tsx │ ├── useFetchArgs.test.tsx │ ├── useMutation.test.tsx │ └── useQuery.test.tsx ├── defaults.ts ├── doFetchArgs.ts ├── index.ts ├── storage │ ├── __tests__ │ │ └── localStorage.test.ts │ ├── localStorage.ts │ └── memoryStorage.ts ├── types.ts ├── useCache.ts ├── useFetch.ts ├── useFetchArgs.ts ├── useMutation.ts ├── useQuery.ts └── utils.ts ├── tsconfig.json ├── tsconfig.production.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | docker: 5 | - image: circleci/node:12.16.1 6 | environment: 7 | - NODE_ENV: test 8 | working_directory: ~/use-http 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | key: use-http-yarn-{{ checksum "yarn.lock" }} 13 | - run: 14 | name: Yarn Install 15 | command: | 16 | yarn install 17 | - save_cache: 18 | key: use-http-yarn-{{ checksum "yarn.lock" }} 19 | paths: 20 | - ~/use-http/node_modules 21 | - run: 22 | name: Run JS Tests 23 | command: yarn test 24 | workflows: 25 | version: 2 26 | build_and_test: 27 | jobs: 28 | - test 29 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:react/recommended", 10 | "standard", 11 | "plugin:jest/recommended" 12 | ], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 2018, 20 | "sourceType": "module", 21 | "ecmaFeatures": { 22 | "jsx": true 23 | } 24 | }, 25 | "plugins": [ 26 | "react", 27 | "@typescript-eslint", 28 | "react-hooks", 29 | "jest", 30 | "jest-formatting" 31 | ], 32 | "rules": { 33 | "@typescript-eslint/member-delimiter-style": [ 34 | "error", 35 | { 36 | "multiline": { 37 | "delimiter": "none", 38 | "requireLast": false 39 | }, 40 | "singleline": { 41 | "delimiter": "comma", 42 | "requireLast": false 43 | } 44 | } 45 | ], 46 | "@typescript-eslint/explicit-member-accessibility": ["off"], 47 | "@typescript-eslint/explicit-function-return-type": ["off"], 48 | "@typescript-eslint/no-explicit-any": ["off"], 49 | "react-hooks/rules-of-hooks": "error", 50 | "react-hooks/exhaustive-deps": "warn", 51 | "jest/consistent-test-it": [ 52 | "error", 53 | { 54 | "fn": "it" 55 | } 56 | ], 57 | "jest-formatting/padding-before-test-blocks": 2, 58 | "jest-formatting/padding-before-describe-blocks": 2, 59 | "react/prop-types": 0, 60 | "prefer-const": "warn", 61 | "no-var": "warn", 62 | "space-before-function-paren": ["error", { 63 | "anonymous": "never", 64 | "named": "never", 65 | "asyncArrow": "always" 66 | }] 67 | }, 68 | "settings": { 69 | "react": { 70 | "version": "detect" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: alex-cory 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **⚠️ Make a Codesandbox ⚠️** 14 | Please do this to easily reproduce the bug. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | Making your first PR 2 | ============================== 3 | 4 | ### 1. **Fork** 5 | Click the fork button. 6 | 7 | ![image](https://user-images.githubusercontent.com/5455859/79698757-d5d0c180-823f-11ea-86e7-8edcd1b969be.png) 8 | 9 | ### 2. **Environment Setup** 10 | Clone your fork and set up your environment for development. I reccommend [iterm](https://www.iterm2.com/), but use whatever you'd like. 11 | 12 | **In terminal window 1** 13 | ```sh 14 | git clone git@github.com:YOUR_USERNAME/use-http.git 15 | cd ./use-http 16 | yarn 17 | yarn link 18 | ``` 19 | **In terminal window 2** (your react app to test `use-http` in) 20 | ```sh 21 | create-react-app use-http-sandbox 22 | cd ./use-http-sandbox 23 | yarn 24 | yarn link use-http 25 | ``` 26 | **In terminal window 1** (inside your forked `use-http` directory) 27 | ```sh 28 | npm link ../use-http-sandbox/node_modules/react 29 | npm link ../use-http-sandbox/node_modules/react-dom 30 | yarn build:watch 31 | ``` 32 | **In terminal window 2** (your react app to test `use-http` in) 33 | ```sh 34 | yarn start 35 | ``` 36 | 37 | ### 3. **Develop** 38 | Now just go into your `use-http-sandbox/src/App.js` and import use-http and now you can develop. When you make changes in `use-http` it should cause `use-http-sandbox` to refresh `localhost:3000`. 39 | 40 | ### 4. **Test** 41 | Once you're done making your changes be sure to make some tests and run all of them. What I do is open up 3 different panes in the same iTerm2 window by pressing `⌘ + D` on mac 2 times. In the far left I do `yarn build:watch`, in the middle I do `yarn test:browser:watch` (where you'll probably be writing your tests) and in the 3rd window I do `yarn test:server:watch`. It looks like this. 42 | ![image](https://user-images.githubusercontent.com/5455859/79790558-bf3e6f00-8300-11ea-89ad-241ce943f1b3.png) 43 | 44 | ### 5. **Push** 45 | Push your changes to your forked repo. 46 | 47 | ### 6. **Make PR** 48 | Once you push your changes, you will see a link in your terminal that looks like this. 49 | ```sh 50 | remote: Create a pull request for 'master' on GitHub by visiting: 51 | remote: https://github.com/YOUR_USERNAME/use-http/pull/new/master 52 | ``` 53 | go to that url. From there you should be able to compare your forked master branch to `ava/use-http:master`. 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Parcel 4 | .cache 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /dist 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Using Yarn 29 | package-lock.json 30 | 31 | # Gitkeep files 32 | !.gitkeep 33 | 34 | # IDE config 35 | .idea 36 | *.iml 37 | /venv 38 | .vscode 39 | 40 | # Error reporting 41 | report.*.json 42 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![use-http logo][3]][5] 2 | 3 |
4 | 5 |

6 |

useFetch

7 |

8 | 9 |
10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | undefined 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 38 | 41 | 44 | 47 | 50 |

51 | 52 |
53 | 54 | 🐶 React hook for making isomorphic http requests 55 |
56 | Main Documentation 57 |
58 |
59 | 60 |
61 |
62 | 63 |
64 |
npm i use-http
65 |
66 | 67 |
68 |
69 | 70 | Features 71 | --------- 72 | 73 | - SSR (server side rendering) support 74 | - TypeScript support 75 | - 2 dependencies ([use-ssr](https://github.com/alex-cory/use-ssr), [urs](https://github.com/alex-cory/urs)) 76 | - GraphQL support (queries + mutations) 77 | - Provider to set default `url` and `options` 78 | - Request/response interceptors 79 | - React Native support 80 | - Aborts/Cancels pending http requests when a component unmounts 81 | - Built in caching 82 | - Persistent caching support 83 | - Suspense(experimental) support 84 | - Retry functionality 85 | 86 | Usage 87 | ----- 88 | 89 | ### Examples + Videos 90 | 91 | - useFetch - managed state, request, response, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-request-response-managed-state-ruyi3?file=/src/index.js) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=_-GujYZFCKI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=6) 92 | - useFetch - request/response interceptors [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-requestresponse-interceptors-s1lex) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=3HauoWh0Jts&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=8) 93 | - useFetch - retries, retryOn, retryDelay [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-retryon-retrydelay-s74q9) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=grE3AX-Q9ss&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=9) 94 | - useFetch - abort, timeout, onAbort, onTimeout [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7SuD3ZOfu7E&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=4) 95 | - useFetch - persist, cache [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=pJ22Rq9c8mw&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=7) 96 | - useFetch - cacheLife, cachePolicy [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=AsZ9hnWHCeg&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=3&t=0s) 97 | - useFetch - suspense (experimental) [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-suspense-i22wv) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7qWLJUpnxHI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=2&t=0s) 98 | - useFetch - pagination [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-pagination-exttg) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=YmcMjRpIYqU&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=5) 99 | - useQuery - GraphQL [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/graphql-usequery-provider-uhdmj) 100 | - useFetch - Next.js [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-in-nextjs-nn9fm) 101 | - useFetch - create-react-app [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/embed/km04k9k9x5) 102 | 103 |
Basic Usage Managed State useFetch 104 | 105 | If the last argument of `useFetch` is not a dependency array `[]`, then it will not fire until you call one of the http methods like `get`, `post`, etc. 106 | 107 | ```js 108 | import useFetch from 'use-http' 109 | 110 | function Todos() { 111 | const [todos, setTodos] = useState([]) 112 | const { get, post, response, loading, error } = useFetch('https://example.com') 113 | 114 | useEffect(() => { initializeTodos() }, []) // componentDidMount 115 | 116 | async function initializeTodos() { 117 | const initialTodos = await get('/todos') 118 | if (response.ok) setTodos(initialTodos) 119 | } 120 | 121 | async function addTodo() { 122 | const newTodo = await post('/todos', { title: 'my new todo' }) 123 | if (response.ok) setTodos([...todos, newTodo]) 124 | } 125 | 126 | return ( 127 | <> 128 | 129 | {error && 'Error!'} 130 | {loading && 'Loading...'} 131 | {todos.map(todo => ( 132 |
{todo.title}
133 | ))} 134 | 135 | ) 136 | } 137 | ``` 138 | 139 | 140 | 141 |
142 | 143 |
Basic Usage Auto-Managed State useFetch 144 | 145 | This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). If no method is specified, GET is the default. 146 | 147 | ```js 148 | import useFetch from 'use-http' 149 | 150 | function Todos() { 151 | const options = {} // these options accept all native `fetch` options 152 | // the last argument below [] means it will fire onMount (GET by default) 153 | const { loading, error, data = [] } = useFetch('https://example.com/todos', options, []) 154 | return ( 155 | <> 156 | {error && 'Error!'} 157 | {loading && 'Loading...'} 158 | {data.map(todo => ( 159 |
{todo.title}
160 | ))} 161 | 162 | ) 163 | } 164 | ``` 165 | 166 | 167 | 168 | 169 |
170 | 171 |
Suspense Mode(experimental) Auto-Managed State 172 | 173 | Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). 174 | 175 | ```js 176 | import useFetch, { Provider } from 'use-http' 177 | 178 | function Todos() { 179 | const { data: todos = [] } = useFetch('/todos', { 180 | suspense: true // A. can put `suspense: true` here 181 | }, []) // onMount 182 | 183 | return todos.map(todo =>
{todo.title}
) 184 | } 185 | 186 | function App() { 187 | const options = { 188 | suspense: true // B. can put `suspense: true` here too 189 | } 190 | return ( 191 | 192 | 193 | 194 | 195 | 196 | ) 197 | } 198 | ``` 199 | 200 | 201 | 202 |
203 | 204 |
Suspense Mode(experimental) Managed State 205 | 206 | Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). Suspense mode via managed state is very experimental. 207 | 208 | ```js 209 | import useFetch, { Provider } from 'use-http' 210 | 211 | function Todos() { 212 | const [todos, setTodos] = useState([]) 213 | // A. can put `suspense: true` here 214 | const { get, response } = useFetch({ suspense: true }) 215 | 216 | const loadInitialTodos = async () => { 217 | const todos = await get('/todos') 218 | if (response.ok) setTodos(todos) 219 | } 220 | 221 | // componentDidMount 222 | useEffect(() => { 223 | loadInitialTodos() 224 | }, []) 225 | 226 | return todos.map(todo =>
{todo.title}
) 227 | } 228 | 229 | function App() { 230 | const options = { 231 | suspense: true // B. can put `suspense: true` here too 232 | } 233 | return ( 234 | 235 | 236 | 237 | 238 | 239 | ) 240 | } 241 | ``` 242 | 243 |
244 | 245 |
246 |
247 |
248 |
249 |

250 | 251 | Consider sponsoring 252 | 253 |
254 |
255 | 256 | Ava 257 | 258 |
259 | Ava, Rapid Application Development 260 |
261 | 262 | Need a freelance software engineer with more than 5 years production experience at companies like Facebook, Discord, Best Buy, and Citrix?
263 | website | email | twitter 264 |
265 |

266 |
267 |
268 |
269 |
270 |
271 | 272 |
Pagination + Provider 273 | 274 | The `onNewData` will take the current data, and the newly fetched data, and allow you to merge the two however you choose. In the example below, we are appending the new todos to the end of the current todos. 275 | 276 | ```jsx 277 | import useFetch, { Provider } from 'use-http' 278 | 279 | const Todos = () => { 280 | const [page, setPage] = useState(1) 281 | 282 | const { data = [], loading } = useFetch(`/todos?page=${page}&amountPerPage=15`, { 283 | onNewData: (currTodos, newTodos) => [...currTodos, ...newTodos], // appends newly fetched todos 284 | perPage: 15, // stops making more requests if last todos fetched < 15 285 | }, [page]) // runs onMount AND whenever the `page` updates (onUpdate) 286 | 287 | return ( 288 | 295 | ) 296 | } 297 | 298 | const App = () => ( 299 | 300 | 301 | 302 | ) 303 | ``` 304 | 305 | 306 | 307 | 308 |
309 | 310 |
Destructured useFetch 311 | 312 | ⚠️ Do not destructure the `response` object! Details in [this video](https://youtu.be/_-GujYZFCKI?list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&t=127). Technically you can do it, but if you need to access the `response.ok` from, for example, within a component's onClick handler, it will be a stale value for `ok` where it will be correct for `response.ok`. ️️⚠️ 313 | 314 | ```js 315 | var [request, response, loading, error] = useFetch('https://example.com') 316 | 317 | // want to use object destructuring? You can do that too 318 | var { 319 | request, 320 | response, // 🚨 Do not destructure the `response` object! 321 | loading, 322 | error, 323 | data, 324 | cache, // methods: get, set, has, delete, clear (like `new Map()`) 325 | get, 326 | post, 327 | put, 328 | patch, 329 | delete // don't destructure `delete` though, it's a keyword 330 | del, // <- that's why we have this (del). or use `request.delete` 331 | head, 332 | options, 333 | connect, 334 | trace, 335 | mutate, // GraphQL 336 | query, // GraphQL 337 | abort 338 | } = useFetch('https://example.com') 339 | 340 | // 🚨 Do not destructure the `response` object! 341 | // 🚨 This just shows what fields are available in it. 342 | var { 343 | ok, 344 | status, 345 | headers, 346 | data, 347 | type, 348 | statusText, 349 | url, 350 | body, 351 | bodyUsed, 352 | redirected, 353 | // methods 354 | json, 355 | text, 356 | formData, 357 | blob, 358 | arrayBuffer, 359 | clone 360 | } = response 361 | 362 | var { 363 | loading, 364 | error, 365 | data, 366 | cache, // methods: get, set, has, delete, clear (like `new Map()`) 367 | get, 368 | post, 369 | put, 370 | patch, 371 | delete // don't destructure `delete` though, it's a keyword 372 | del, // <- that's why we have this (del). or use `request.delete` 373 | mutate, // GraphQL 374 | query, // GraphQL 375 | abort 376 | } = request 377 | ``` 378 | 379 | 380 | 381 |
382 | 383 | 384 |
Relative routes useFetch 385 | 386 | ```jsx 387 | var request = useFetch('https://example.com') 388 | 389 | request.post('/todos', { 390 | no: 'way' 391 | }) 392 | ``` 393 | 394 | 395 | 396 |
397 | 398 |
Abort useFetch 399 | 400 | 401 | 402 | 403 | ```jsx 404 | const { get, abort, loading, data: repos } = useFetch('https://api.github.com/search/repositories?q=') 405 | 406 | // the line below is not isomorphic, but for simplicity we're using the browsers `encodeURI` 407 | const searchGithubRepos = e => get(encodeURI(e.target.value)) 408 | 409 | <> 410 | 411 | 412 | {loading ? 'Loading...' : repos.data.items.map(repo => ( 413 |
{repo.name}
414 | ))} 415 | 416 | ``` 417 | 418 | 419 | 420 |
421 | 422 | 423 |
GraphQL Query useFetch 424 | 425 | ```jsx 426 | 427 | const QUERY = ` 428 | query Todos($userID string!) { 429 | todos(userID: $userID) { 430 | id 431 | title 432 | } 433 | } 434 | ` 435 | 436 | function App() { 437 | const request = useFetch('http://example.com') 438 | 439 | const getTodosForUser = id => request.query(QUERY, { userID: id }) 440 | 441 | return ( 442 | <> 443 | 444 | {request.loading ? 'Loading...' :
{request.data}
} 445 | 446 | ) 447 | } 448 | ``` 449 |
450 | 451 | 452 |
GraphQL Mutation useFetch 453 | 454 | The `Provider` allows us to set a default `url`, `options` (such as headers) and so on. 455 | 456 | ```jsx 457 | 458 | const MUTATION = ` 459 | mutation CreateTodo($todoTitle string) { 460 | todo(title: $todoTitle) { 461 | id 462 | title 463 | } 464 | } 465 | ` 466 | 467 | function App() { 468 | const [todoTitle, setTodoTitle] = useState('') 469 | const request = useFetch('http://example.com') 470 | 471 | const createtodo = () => request.mutate(MUTATION, { todoTitle }) 472 | 473 | return ( 474 | <> 475 | setTodoTitle(e.target.value)} /> 476 | 477 | {request.loading ? 'Loading...' :
{request.data}
} 478 | 479 | ) 480 | } 481 | ``` 482 |
483 | 484 | 485 |
Provider using the GraphQL useMutation and useQuery 486 | 487 | ##### Query for todos 488 | ```jsx 489 | import { useQuery } from 'use-http' 490 | 491 | export default function QueryComponent() { 492 | 493 | // can also do it this way: 494 | // const [data, loading, error, query] = useQuery` 495 | // or this way: 496 | // const { data, loading, error, query } = useQuery` 497 | const request = useQuery` 498 | query Todos($userID string!) { 499 | todos(userID: $userID) { 500 | id 501 | title 502 | } 503 | } 504 | ` 505 | 506 | const getTodosForUser = id => request.query({ userID: id }) 507 | 508 | return ( 509 | <> 510 | 511 | {request.loading ? 'Loading...' :
{request.data}
} 512 | 513 | ) 514 | } 515 | ``` 516 | 517 | 518 | 519 | 520 | ##### Add a new todo 521 | ```jsx 522 | import { useMutation } from 'use-http' 523 | 524 | export default function MutationComponent() { 525 | const [todoTitle, setTodoTitle] = useState('') 526 | 527 | // can also do it this way: 528 | // const request = useMutation` 529 | // or this way: 530 | // const { data, loading, error, mutate } = useMutation` 531 | const [data, loading, error, mutate] = useMutation` 532 | mutation CreateTodo($todoTitle string) { 533 | todo(title: $todoTitle) { 534 | id 535 | title 536 | } 537 | } 538 | ` 539 | 540 | const createTodo = () => mutate({ todoTitle }) 541 | 542 | return ( 543 | <> 544 | setTodoTitle(e.target.value)} /> 545 | 546 | {loading ? 'Loading...' :
{data}
} 547 | 548 | ) 549 | } 550 | ``` 551 | 552 | 553 | ##### Adding the Provider 554 | These props are defaults used in every request inside the ``. They can be overwritten individually 555 | ```jsx 556 | import { Provider } from 'use-http' 557 | import QueryComponent from './QueryComponent' 558 | import MutationComponent from './MutationComponent' 559 | 560 | function App() { 561 | 562 | const options = { 563 | headers: { 564 | Authorization: 'Bearer YOUR_TOKEN_HERE' 565 | } 566 | } 567 | 568 | return ( 569 | 570 | 571 | 572 | 573 | ) 574 | } 575 | 576 | ``` 577 |
578 | 579 | 580 |
Request/Response Interceptors 581 | 582 | This example shows how we can do authentication in the `request` interceptor and how we can camelCase the results in the `response` interceptor 583 | 584 | ```jsx 585 | import { Provider } from 'use-http' 586 | import { toCamel } from 'convert-keys' 587 | 588 | function App() { 589 | let [token, setToken] = useLocalStorage('token') 590 | 591 | const options = { 592 | interceptors: { 593 | // every time we make an http request, this will run 1st before the request is made 594 | // url, path and route are supplied to the interceptor 595 | // request options can be modified and must be returned 596 | request: async ({ options, url, path, route }) => { 597 | if (isExpired(token)) { 598 | token = await getNewToken() 599 | setToken(token) 600 | } 601 | options.headers.Authorization = `Bearer ${token}` 602 | return options 603 | }, 604 | // every time we make an http request, before getting the response back, this will run 605 | response: async ({ response, request }) => { 606 | // unfortunately, because this is a JS Response object, we have to modify it directly. 607 | // It shouldn't have any negative affect since this is getting reset on each request. 608 | const res = response 609 | if (res.data) res.data = toCamel(res.data) 610 | return res 611 | } 612 | } 613 | } 614 | 615 | return ( 616 | 617 | 618 | 619 | ) 620 | } 621 | 622 | ``` 623 | 624 | 625 | 626 |
627 | 628 |
File Uploads (FormData) 629 | 630 | This example shows how we can upload a file using `useFetch`. 631 | 632 | ```jsx 633 | import useFetch from 'use-http' 634 | 635 | const FileUploader = () => { 636 | const [file, setFile] = useState() 637 | 638 | const { post } = useFetch('https://example.com/upload') 639 | 640 | const uploadFile = async () => { 641 | const data = new FormData() 642 | data.append('file', file) 643 | if (file instanceof FormData) await post(data) 644 | } 645 | 646 | return ( 647 |
648 | {/* Drop a file onto the input below */} 649 | setFile(e.target.files[0])} /> 650 | 651 |
652 | ) 653 | } 654 | ``` 655 |
656 | 657 |
Handling Different Response Types 658 | 659 | This example shows how we can get `.json()`, `.text()`, `.formData()`, `.blob()`, `.arrayBuffer()`, and all the other [http response methods](https://developer.mozilla.org/en-US/docs/Web/API/Response#Methods). By default, `useFetch` 1st tries to call `response.json()` under the hood, if that fails it's backup is `response.text()`. If that fails, then you need a different response type which is where this comes in. 660 | 661 | ```js 662 | import useFetch from 'use-http' 663 | 664 | const App = () => { 665 | const [name, setName] = useState('') 666 | 667 | const { get, loading, error, response } = useFetch('http://example.com') 668 | 669 | const handleClick = async () => { 670 | await get('/users/1?name=true') // will return just the user's name 671 | const text = await response.text() 672 | setName(text) 673 | } 674 | 675 | return ( 676 | <> 677 | 678 | {error && error.messge} 679 | {loading && "Loading..."} 680 | {name &&
{name}
} 681 | 682 | ) 683 | } 684 | ``` 685 | 686 | 687 | 688 | 689 |
690 | 691 |
Overwrite/Remove Options/Headers Set in Provider 692 | 693 | This example shows how to remove a header all together. Let's say you have ``, but for one api call, you don't want that header in your `useFetch` at all for one instance in your app. This would allow you to remove that. 694 | 695 | ```js 696 | import useFetch from 'use-http' 697 | 698 | const Todos = () => { 699 | // let's say for this request, you don't want the `Accept` header at all 700 | const { loading, error, data: todos = [] } = useFetch('/todos', globalOptions => { 701 | delete globalOptions.headers.Accept 702 | return globalOptions 703 | }, []) // onMount 704 | return ( 705 | <> 706 | {error && error.messge} 707 | {loading && "Loading..."} 708 | {todos && } 709 | 710 | ) 711 | } 712 | 713 | const App = () => { 714 | const options = { 715 | headers: { 716 | Accept: 'application/json' 717 | } 718 | } 719 | return ( 720 | 721 | } 722 | ``` 723 | 724 |
725 | 726 |
Retries retryOn & retryDelay 727 | 728 | In this example you can see how `retryOn` will retry on a status code of `305`, or if we choose the `retryOn()` function, it returns a boolean to decide if we will retry. With `retryDelay` we can either have a fixed delay, or a dynamic one by using `retryDelay()`. Make sure `retries` is set to at minimum `1` otherwise it won't retry the request. If `retries > 0` without `retryOn` then by default we always retry if there's an error or if `!response.ok`. If `retryOn: [400]` and `retries > 0` then we only retry on a response status of `400`. 729 | 730 | ```js 731 | import useFetch from 'use-http' 732 | 733 | const TestRetry = () => { 734 | const { response, get } = useFetch('https://httpbin.org/status/305', { 735 | // make sure `retries` is set otherwise it won't retry 736 | retries: 1, 737 | retryOn: [305], 738 | // OR 739 | retryOn: async ({ attempt, error, response }) => { 740 | // returns true or false to determine whether to retry 741 | return error || response && response.status >= 300 742 | }, 743 | 744 | retryDelay: 3000, 745 | // OR 746 | retryDelay: ({ attempt, error, response }) => { 747 | // exponential backoff 748 | return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000) 749 | // linear backoff 750 | return attempt * 1000 751 | } 752 | }) 753 | 754 | return ( 755 | <> 756 | 757 |
{JSON.stringify(response, null, 2)}
758 | 759 | ) 760 | } 761 | ``` 762 | 763 | 764 | 765 |
766 | 767 | Overview 768 | -------- 769 | 770 | ### Hooks 771 | 772 | | Hook | Description | 773 | | --------------------- | ---------------------------------------------------------------------------------------- | 774 | | `useFetch` | The base hook | 775 | | `useQuery` | For making a GraphQL query | 776 | | `useMutation` | For making a GraphQL mutation | 777 | 778 | 779 | 780 | 781 | ### Options 782 | 783 | This is exactly what you would pass to the normal js `fetch`, with a little extra. All these options can be passed to the ``, or directly to `useFetch`. If you have both in the `` and in `useFetch`, the `useFetch` options will overwrite the ones from the `` 784 | 785 | | Option | Description | Default | 786 | | --------------------- | --------------------------------------------------------------------------|------------- | 787 | | `cacheLife` | After a successful cache update, that cache data will become stale after this duration | `0` | 788 | | `cachePolicy` | These will be the same ones as Apollo's [fetch policies](https://www.apollographql.com/docs/react/api/react/hoc/#optionsfetchpolicy). Possible values are `cache-and-network`, `network-only`, `cache-only`, `no-cache`, `cache-first`. Currently only supports **`cache-first`** or **`no-cache`** | `cache-first` | 789 | | `data` | Allows you to set a default value for `data` | `undefined` | 790 | | `interceptors.request` | Allows you to do something before an http request is sent out. Useful for authentication if you need to refresh tokens a lot. | `undefined` | 791 | | `interceptors.response` | Allows you to do something after an http response is recieved. Useful for something like camelCasing the keys of the response. | `undefined` | 792 | | `loading` | Allows you to set default value for `loading` | `false` unless the last argument of `useFetch` is `[]` | 793 | | `onAbort` | Runs when the request is aborted. | empty function | 794 | | `onError` | Runs when the request get's an error. If retrying, it is only called on the last retry attempt. | empty function | 795 | | `onNewData` | Merges the current data with the incoming data. Great for pagination. | `(curr, new) => new` | 796 | | `onTimeout` | Called when the request times out. | empty function | 797 | | `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` | 798 | | `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` | 799 | | `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` | 800 | | `retries` | When a request fails or times out, retry the request this many times. By default it will not retry. | `0` | 801 | | `retryDelay` | You can retry with certain intervals i.e. 30 seconds `30000` or with custom logic (i.e. to increase retry intervals). | `1000` | 802 | | `retryOn` | You can retry on certain http status codes or have custom logic to decide whether to retry or not via a function. Make sure `retries > 0` otherwise it won't retry. | `[]` | 803 | | `suspense` | Enables Experimental React Suspense mode. [example](https://codesandbox.io/s/usefetch-suspense-i22wv) | `false` | 804 | | `timeout` | The request will be aborted/cancelled after this amount of time. This is also the interval at which `retries` will be made at. **in milliseconds**. If set to `0`, it will not timeout except for browser defaults. | `0` | 805 | 806 | ```jsx 807 | const options = { 808 | // accepts all `fetch` options such as headers, method, etc. 809 | 810 | // The time in milliseconds that cache data remains fresh. 811 | cacheLife: 0, 812 | 813 | // Cache responses to improve speed and reduce amount of requests 814 | // Only one request to the same endpoint will be initiated unless cacheLife expires for 'cache-first'. 815 | cachePolicy: 'cache-first', // 'no-cache' 816 | 817 | // set's the default for the `data` field 818 | data: [], 819 | 820 | // typically, `interceptors` would be added as an option to the `` 821 | interceptors: { 822 | request: async ({ options, url, path, route }) => { // `async` is not required 823 | return options // returning the `options` is important 824 | }, 825 | response: async ({ response, request }) => { 826 | // notes: 827 | // - `response.data` is equivalent to `await response.json()` 828 | // - `request` is an object matching the standard fetch's options 829 | return response // returning the `response` is important 830 | } 831 | }, 832 | 833 | // set's the default for `loading` field 834 | loading: false, 835 | 836 | // called when aborting the request 837 | onAbort: () => {}, 838 | 839 | // runs when an error happens. 840 | onError: ({ error }) => {}, 841 | 842 | // this will allow you to merge the `data` for pagination. 843 | onNewData: (currData, newData) => { 844 | return [...currData, ...newData] 845 | }, 846 | 847 | // called when the request times out 848 | onTimeout: () => {}, 849 | 850 | // this will tell useFetch not to run the request if the list doesn't haveMore. (pagination) 851 | // i.e. if the last page fetched was < 15, don't run the request again 852 | perPage: 15, 853 | 854 | // Allows caching to persist after page refresh. Only supported in the Browser currently. 855 | persist: false, 856 | 857 | // this would basically call `await response.json()` 858 | // and set the `data` and `response.data` field to the output 859 | responseType: 'json', 860 | // OR can be an array. It's an array by default. 861 | // We will try to get the `data` by attempting to extract 862 | // it via these body interface methods, one by one in 863 | // this order. We skip `formData` because it's mostly used 864 | // for service workers. 865 | responseType: ['json', 'text', 'blob', 'arrayBuffer'], 866 | 867 | // amount of times it should retry before erroring out 868 | retries: 3, 869 | 870 | // The time between retries 871 | retryDelay: 10000, 872 | // OR 873 | // Can be a function which is used if we want change the time in between each retry 874 | retryDelay({ attempt, error, response }) { 875 | // exponential backoff 876 | return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000) 877 | // linear backoff 878 | return attempt * 1000 879 | }, 880 | 881 | // make sure `retries` is set otherwise it won't retry 882 | // can retry on certain http status codes 883 | retryOn: [503], 884 | // OR 885 | async retryOn({ attempt, error, response }) { 886 | // retry on any network error, or 4xx or 5xx status codes 887 | if (error !== null || response.status >= 400) { 888 | console.log(`retrying, attempt number ${attempt + 1}`); 889 | return true; 890 | } 891 | }, 892 | 893 | // enables experimental React Suspense mode 894 | suspense: true, // defaults to `false` 895 | 896 | // amount of time before the request get's canceled/aborted 897 | timeout: 10000, 898 | } 899 | 900 | useFetch(options) 901 | // OR 902 | 903 | ``` 904 | 905 | Who's using use-http? 906 | ---------------------- 907 | 908 | Does your company use use-http? Consider sponsoring the project to fund new features, bug fixes, and more. 909 | 910 |

911 | 912 | 913 | 914 | 915 | 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | 928 |

929 | 930 | Browser Support 931 | --------------- 932 | 933 | If you need support for IE, you will need to add additional polyfills. The React docs suggest [these polyfills][4], but from [this issue][2] we have found it to work fine with the [`react-app-polyfill`]. If you have any updates to this browser list, please submit a PR! 934 | 935 | | [IE / Edge]()
Edge | [Firefox]()
Firefox | [Chrome]()
Chrome | [Safari]()
Safari | [Opera]()
Opera | 936 | | --------- | --------- | --------- | --------- | --------- | 937 | | 12+ | last 2 versions| last 2 versions| last 2 versions| last 2 versions | 938 | 939 | Feature Requests/Ideas 940 | ---------------------- 941 | 942 | If you have feature requests, [submit an issue][1] to let us know what you would like to see! 943 | 944 | Todos 945 | ------ 946 | - [ ] [OSS analytics](https://www.npmjs.com/package/@scarf/scarf) 947 | - [ ] [contributors](https://github.com/all-contributors/all-contributors) 948 | - [ ] prefetching 949 | - [ ] global cache state management 950 | - [ ] optimistic updates 951 | - [ ] `persist` support for React Native 952 | - [ ] better loading state management. When using only 1 useFetch in a component and we use 953 | `Promise.all([get('/todos/1'), get('/todos/2')])` then don't have a loading true, 954 | loading false on each request. Just have loading true on 1st request, and loading false 955 | on last request. 956 | - [ ] is making a [gitpod](https://www.gitpod.io/docs/configuration/) useful here? 🤔 957 | - [ ] suspense 958 | - [ ] triggering it from outside the `` component. 959 | - add `.read()` to `request` 960 | - or make it work with just the `suspense: true` option 961 | - both of these options need to be thought out a lot more^ 962 | - [ ] tests for this^ (triggering outside) 963 | - [ ] cleanup tests in general. Snapshot tests are unpredictably not working for some reason. 964 | - snapshot test resources: [swr](https://github.com/zeit/swr/blob/master/test/use-swr.test.tsx#L1083), [react-apollo-hooks](https://github.com/trojanowski/react-apollo-hooks/blob/master/src/__tests__/useQuery-test.tsx#L218) 965 | - basic test resources: [fetch-suspense](https://github.com/CharlesStover/fetch-suspense/blob/master/tests/create-use-fetch.test.ts), [@testing-library/react-hooks suspense PR](https://github.com/testing-library/react-hooks-testing-library/pull/35/files) 966 | - [ ] maybe add translations [like this one](https://github.com/jamiebuilds/unstated-next) 967 | - [ ] maybe add contributors [all-contributors](https://github.com/all-contributors/all-contributors) 968 | - [ ] add sponsors [similar to this](https://github.com/carbon-app/carbon) 969 | - [ ] Error handling 970 | - [ ] if calling `response.json()` and there is no response yet 971 | - [ ] tests 972 | - [ ] tests for SSR 973 | - [ ] tests for react native [see here](https://stackoverflow.com/questions/45842088/react-native-mocking-formdata-in-unit-tests) 974 | - [ ] tests for GraphQL hooks `useMutation` + `useQuery` 975 | - [ ] tests for stale `response` see this [PR](https://github.com/ava/use-http/pull/119/files) 976 | - [ ] tests to make sure `response.formData()` and some of the other http `response methods` work properly 977 | - [ ] the `onMount` works properly with all variants of passing `useEffect(fn, [request.get])` and not causing an infinite loop 978 | - [ ] `async` tests for `interceptors.response` 979 | - [ ] aborts fetch on unmount 980 | - [ ] does not abort fetch on every rerender 981 | - [ ] `retryDelay` and `timeout` are both set. It works, but is annoying to deal with timers in tests. [resource](https://github.com/fac-13/HP-game/issues/9) 982 | - [ ] `timeout` with `retries > 0`. (also do `retires > 1`) Need to figure out how to advance timers properly to write this and the test above 983 | - [ ] take a look at how [react-apollo-hooks](https://github.com/trojanowski/react-apollo-hooks) work. Maybe ad `useSubscription` and `const request = useFetch(); request.subscribe()` or something along those lines 984 | - [ ] make this a github package 985 | - [see ava packages](https://github.com/orgs/ava/packages) 986 | - [ ] Documentation: 987 | - [ ] show comparison with Apollo 988 | - [ ] figure out a good way to show side-by-side comparisons 989 | - [ ] show comparison with Axios 990 | - [ ] potential option ideas 991 | 992 | ```jsx 993 | const request = useFetch({ 994 | graphql: { 995 | // all options can also be put in here 996 | // to overwrite those of `useFetch` for 997 | // `useMutation` and `useQuery` 998 | }, 999 | // by default this is true, but if set to false 1000 | // then we default to the responseType array of trying 'json' first, then 'text', etc. 1001 | // hopefully I get some answers on here: https://bit.ly/3afPlJS 1002 | responseTypeGuessing: true, 1003 | 1004 | // Allows you to pass in your own cache to useFetch 1005 | // This is controversial though because `cache` is an option in the requestInit 1006 | // and it's value is a string. See: https://developer.mozilla.org/en-US/docs/Web/API/Request/cache 1007 | // One possible solution is to move the default `fetch`'s `cache` to `cachePolicy`. 1008 | // I don't really like this solution though. 1009 | // Another solution is to only allow the `cache` option with the `` 1010 | cache: new Map(), 1011 | // these will be the exact same ones as Apollo's 1012 | cachePolicy: 'cache-and-network', 'network-only', 'cache-only', 'no-cache' // 'cache-first' 1013 | // potential idea to fetch on server instead of just having `loading` state. Not sure if this is a good idea though 1014 | onServer: true, 1015 | onSuccess: (/* idk what to put here */) => {}, 1016 | // if you would prefer to pass the query in the config 1017 | query: `some graphql query` 1018 | // if you would prefer to pass the mutation in the config 1019 | mutation: `some graphql mutation` 1020 | refreshWhenHidden: false, 1021 | }) 1022 | 1023 | 1024 | // potential for causing a rerender after clearing cache if needed 1025 | request.cache.clear(true) 1026 | ``` 1027 | 1028 | - [ ] potential option ideas for `GraphQL` 1029 | 1030 | ```jsx 1031 | const request = useQuery({ onMount: true })`your graphql query` 1032 | 1033 | const request = useFetch(...) 1034 | const userID = 'some-user-uuid' 1035 | const res = await request.query({ userID })` 1036 | query Todos($userID string!) { 1037 | todos(userID: $userID) { 1038 | id 1039 | title 1040 | } 1041 | } 1042 | ` 1043 | ``` 1044 | 1045 | - [ ] make code editor plugin/package/extension that adds GraphQL syntax highlighting for `useQuery` and `useMutation` 😊 1046 | 1047 | - [ ] add React Native test suite 1048 | 1049 | [1]: https://github.com/ava/use-http/issues/new?title=[Feature%20Request]%20YOUR_FEATURE_NAME 1050 | [2]: https://github.com/ava/use-http/issues/93#issuecomment-600896722 1051 | [3]: https://github.com/ava/use-http/raw/master/public/dog.png 1052 | [4]: https://reactjs.org/docs/javascript-environment-requirements.html 1053 | [5]: https://use-http.com 1054 | [`react-app-polyfill`]: https://www.npmjs.com/package/react-app-polyfill 1055 | -------------------------------------------------------------------------------- /config/jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const path = require('path') 3 | 4 | module.exports = { 5 | rootDir: process.cwd(), 6 | coverageDirectory: '/.coverage', 7 | globals: { 8 | __DEV__: true 9 | }, 10 | collectCoverageFrom: [ 11 | 'src/**/*.{js,jsx,ts,tsx}', 12 | '!src/**/*.d.ts', 13 | '!src/**/*.test.*', 14 | '!src/test/**/*.*' 15 | ], 16 | setupFilesAfterEnv: [path.join(__dirname, './setupTests.ts')], 17 | testMatch: [ 18 | '/src/**/__tests__/**/*.ts?(x)', 19 | '/src/**/?(*.)(spec|test).ts?(x)' 20 | ], 21 | testEnvironment: 'node', 22 | testURL: 'http://localhost', 23 | transform: { 24 | '^.+\\.(ts|tsx)$': 'ts-jest' 25 | }, 26 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], 27 | testPathIgnorePatterns: ['/src/__tests__/test-utils.tsx'], 28 | moduleNameMapper: { 29 | '^react-native$': 'react-native-web' 30 | }, 31 | moduleFileExtensions: [ 32 | 'web.js', 33 | 'js', 34 | 'json', 35 | 'web.jsx', 36 | 'jsx', 37 | 'ts', 38 | 'tsx', 39 | 'feature', 40 | 'csv' 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /config/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { GlobalWithFetchMock } from 'jest-fetch-mock' 2 | 3 | const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock 4 | customGlobal.fetch = require('jest-fetch-mock') 5 | customGlobal.fetchMock = customGlobal.fetch 6 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/docs/.nojekyll -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | use-http.com -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ![use-http logo][3] 2 | 3 |

4 |

useFetch

5 |

6 |

🐶 React hook for making isomorphic http requests

7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | undefined 19 | 20 | 21 | undefined 22 | 23 | 24 | Known Vulnerabilities 25 | 26 | 27 | Known Vulnerabilities 28 | 29 | 30 | undefined 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |

40 | 41 | 42 |

43 | Need to fetch some data? Try this one out. It's an isomorphic fetch hook. That means it works with SSR (server side rendering). 44 |

45 |
46 |

47 | A note on the documentation below. Many of these examples could have performance improvements using useMemo and useCallback, but for the sake of the beginner/ease of reading, they are left out. 48 |

49 | 50 | Features 51 | ========= 52 | 53 | - SSR (server side rendering) support 54 | - TypeScript support 55 | - 2 dependencies ([use-ssr](https://github.com/alex-cory/use-ssr), [urs](https://github.com/alex-cory/urs)) 56 | - GraphQL support (queries + mutations) 57 | - Provider to set default `url` and `options` 58 | - Request/response interceptors 59 | - React Native support 60 | - Aborts/Cancels pending http requests when a component unmounts 61 | - Built in caching 62 | - Persistent caching support 63 | - Suspense(experimental) support 64 | - Retry functionality 65 | 66 | Examples + Videos 67 | ========= 68 | 69 | - useFetch - managed state, request, response, etc. [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-request-response-managed-state-ruyi3?file=/src/index.js) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=_-GujYZFCKI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=6) 70 | - useFetch - request/response interceptors [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-requestresponse-interceptors-s1lex) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=3HauoWh0Jts&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=8) 71 | - useFetch - retries, retryOn, retryDelay [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-retryon-retrydelay-s74q9) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=grE3AX-Q9ss&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=9) 72 | - useFetch - abort, timeout, onAbort, onTimeout [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7SuD3ZOfu7E&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=4) 73 | - useFetch - persist, cache [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=pJ22Rq9c8mw&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=7) 74 | - useFetch - cacheLife, cachePolicy [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=AsZ9hnWHCeg&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=3&t=0s) 75 | - useFetch - suspense (experimental) [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-suspense-i22wv) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=7qWLJUpnxHI&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=2&t=0s) 76 | - useFetch - pagination [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-provider-pagination-exttg) [![](https://img.shields.io/badge/video-red.svg)](https://www.youtube.com/watch?v=YmcMjRpIYqU&list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&index=5) 77 | - useQuery - GraphQL [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/graphql-usequery-provider-uhdmj) 78 | - useFetch - Next.js [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/s/usefetch-in-nextjs-nn9fm) 79 | - useFetch - create-react-app [![](https://img.shields.io/badge/example-blue.svg)](https://codesandbox.io/embed/km04k9k9x5) 80 | 81 | Installation 82 | ============= 83 | 84 | ```shell 85 | yarn add use-http or npm i -S use-http 86 | ``` 87 | 88 |
89 |
90 |
91 |
92 |

93 | 94 | Consider sponsoring 95 | 96 |
97 |
98 | 99 | Ava 100 | 101 |
102 | Ava, Rapid Application Development 103 |
104 | 105 | Need a freelance software engineer with more than 5 years production experience at companies like Facebook, Discord, Best Buy, and Citrix?
106 | website | email | twitter 107 |
108 |

109 |
110 |
111 |
112 |
113 | 114 | Usage 115 | ============= 116 | 117 | Basic Usage Auto-Managed State 118 | ------------------- 119 | 120 | This fetch is run `onMount/componentDidMount`. The last argument `[]` means it will run `onMount`. If you pass it a variable like `[someVariable]`, it will run `onMount` and again whenever `someVariable` changes values (aka `onUpdate`). If no method is specified, GET is the default. 121 | 122 | ```js 123 | import useFetch from 'use-http' 124 | 125 | function Todos() { 126 | const options = {} // these options accept all native `fetch` options 127 | // the last argument below [] means it will fire onMount (GET by default) 128 | const { loading, error, data = [] } = useFetch('https://example.com/todos', options, []) 129 | return ( 130 | <> 131 | {error && 'Error!'} 132 | {loading && 'Loading...'} 133 | {data.map(todo => ( 134 |
{todo.title}
135 | )} 136 | 137 | ) 138 | } 139 | ``` 140 | 141 | 142 | 143 | 144 | Managed State Usage 145 | ------------------- 146 | 147 | If the last argument of `useFetch` is not a dependency array `[]`, then it will not fire until you call one of the http methods like `get`, `post`, etc. 148 | 149 | ```js 150 | import useFetch from 'use-http' 151 | 152 | function Todos() { 153 | const [todos, setTodos] = useState([]) 154 | const { get, post, response, loading, error } = useFetch('https://example.com') 155 | 156 | useEffect(() => { loadInitialTodos() }, []) // componentDidMount 157 | 158 | async function loadInitialTodos() { 159 | const initialTodos = await get('/todos') 160 | if (response.ok) setTodos(initialTodos) 161 | } 162 | 163 | async function addTodo() { 164 | const newTodo = await post('/todos', { title: 'my new todo' }) 165 | if (response.ok) setTodos([...todos, newTodo]) 166 | } 167 | 168 | return ( 169 | <> 170 | 171 | {error && 'Error!'} 172 | {loading && 'Loading...'} 173 | {todos.map(todo => ( 174 |
{todo.title}
175 | )} 176 | 177 | ) 178 | } 179 | ``` 180 | 181 | 182 | 183 | Suspense Mode Auto-Managed State 184 | ---------------------------------- 185 | 186 | ```js 187 | import useFetch, { Provider } from 'use-http' 188 | 189 | function Todos() { 190 | const { data: todos = [] } = useFetch('/todos', { 191 | suspense: true // can put it in 2 places. Here or in Provider 192 | }, []) // onMount 193 | 194 | return todos.map(todo =>
{todo.title}
) 195 | } 196 | 197 | function App() { 198 | const options = { 199 | suspense: true 200 | } 201 | return ( 202 | 203 | 204 | 205 | 206 | 207 | ) 208 | } 209 | ``` 210 | 211 | 212 | 213 | Suspense Mode Managed State 214 | ----------------------------- 215 | 216 | Can put `suspense` in 2 places. Either `useFetch` (A) or `Provider` (B). 217 | 218 | ```js 219 | import useFetch, { Provider } from 'use-http' 220 | 221 | function Todos() { 222 | const [todos, setTodos] = useState([]) 223 | // A. can put `suspense: true` here 224 | const { get, response } = useFetch({ suspense: true }) 225 | 226 | const loadInitialTodos = async () => { 227 | const todos = await get('/todos') 228 | if (response.ok) setTodos(todos) 229 | } 230 | 231 | // componentDidMount 232 | useEffect(() => { 233 | loadInitialTodos() 234 | }, []) 235 | 236 | 237 | return todos.map(todo =>
{todo.title}
) 238 | } 239 | 240 | function App() { 241 | const options = { 242 | suspense: true // B. can put `suspense: true` here too 243 | } 244 | return ( 245 | 246 | 247 | 248 | 249 | 250 | ) 251 | } 252 | ``` 253 | 254 | Pagination With Provider 255 | --------------------------- 256 | 257 | The `onNewData` will take the current data, and the newly fetched data, and allow you to merge the two however you choose. In the example below, we are appending the new todos to the end of the current todos. 258 | 259 | ```js 260 | import useFetch, { Provider } from 'use-http' 261 | 262 | const Todos = () => { 263 | const [page, setPage] = useState(1) 264 | 265 | const { data = [], loading } = useFetch(`/todos?page=${page}&amountPerPage=15`, { 266 | onNewData: (currTodos, newTodos) => [...currTodos, ...newTodos], // appends newly fetched todos 267 | perPage: 15, // stops making more requests if last todos fetched < 15 268 | }, [page]) // runs onMount AND whenever the `page` updates (onUpdate) 269 | 270 | return ( 271 |
    272 | {data.map(todo =>
  • {todo.title}
  • } 273 | {loading && 'Loading...'} 274 | {!loading && ( 275 | 276 | )} 277 |
278 | ) 279 | } 280 | 281 | const App = () => ( 282 | 283 | 284 | 285 | ) 286 | ``` 287 | 288 | 289 | 290 | Destructured 291 | ------------- 292 | 293 | ⚠️ Do not destructure the `response` object! Details in [this video](https://youtu.be/_-GujYZFCKI?list=PLZIwrWkE9rCdUybd8t3tY-mUMvXkCdenW&t=127). Technically you can do it, but if you need to access the `response.ok` from, for example, within a component's onClick handler, it will be a stale value for `ok` where it will be correct for `response.ok`. ️️⚠️ 294 | 295 | ```js 296 | var [request, response, loading, error] = useFetch('https://example.com') 297 | 298 | // want to use object destructuring? You can do that too 299 | var { 300 | request, 301 | response, // 🚨 Do not destructure the `response` object! 302 | loading, 303 | error, 304 | data, 305 | cache, // .has(), .clear(), .delete(), .get(), .set() (similar to JS Map) 306 | get, 307 | post, 308 | put, 309 | patch, 310 | delete // don't destructure `delete` though, it's a keyword 311 | del, // <- that's why we have this (del). or use `request.delete` 312 | head, 313 | options, 314 | connect, 315 | trace, 316 | mutate, // GraphQL 317 | query, // GraphQL 318 | abort 319 | } = useFetch('https://example.com') 320 | 321 | // 🚨 Do not destructure the `response` object! 322 | // 🚨 This just shows what fields are available in it. 323 | var { 324 | ok, 325 | status, 326 | headers, 327 | data, 328 | type, 329 | statusText, 330 | url, 331 | body, 332 | bodyUsed, 333 | redirected, 334 | // methods 335 | json, 336 | text, 337 | formData, 338 | blob, 339 | arrayBuffer, 340 | clone 341 | } = response 342 | 343 | var { 344 | loading, 345 | error, 346 | data, 347 | cache, // .has(), .clear(), .delete(), .get(), .set() (similar to JS Map) 348 | get, 349 | post, 350 | put, 351 | patch, 352 | delete // don't destructure `delete` though, it's a keyword 353 | del, // <- that's why we have this (del). or use `request.delete` 354 | mutate, // GraphQL 355 | query, // GraphQL 356 | abort 357 | } = request 358 | ``` 359 | 360 | 361 | 362 | Relative routes 363 | --------------- 364 | 365 | ```js 366 | var request = useFetch('https://example.com') 367 | 368 | request.post('/todos', { 369 | no: 'way' 370 | }) 371 | ``` 372 | 373 | 374 | 375 | Abort 376 | ----- 377 | 378 | 379 | 380 | ```js 381 | const { get, abort, loading, data: repos } = useFetch('https://api.github.com/search/repositories?q=') 382 | 383 | // the line below is not isomorphic, but for simplicity we're using the browsers `encodeURI` 384 | const searchGithubRepos = e => get(encodeURI(e.target.value)) 385 | 386 | <> 387 | 388 | 389 | {loading ? 'Loading...' : repos?.data?.items?.map(repo => ( 390 |
{repo.name}
391 | ))} 392 | 393 | ``` 394 | 395 | 396 | 397 | Request/Response Interceptors with `Provider` 398 | --------------------------------------------- 399 | 400 | This example shows how we can do authentication in the `request` interceptor and how we can camelCase the results in the `response` interceptor 401 | 402 | ```js 403 | import { Provider } from 'use-http' 404 | import { toCamel } from 'convert-keys' 405 | 406 | function App() { 407 | let [token, setToken] = useLocalStorage('token') 408 | 409 | const options = { 410 | interceptors: { 411 | // every time we make an http request, this will run 1st before the request is made 412 | // url, path and route are supplied to the interceptor 413 | // request options can be modified and must be returned 414 | request: async ({ options, url, path, route }) => { 415 | if (isExpired(token)) { 416 | token = await getNewToken() 417 | setToken(token) 418 | } 419 | options.headers.Authorization = `Bearer ${token}` 420 | return options 421 | }, 422 | // every time we make an http request, before getting the response back, this will run 423 | response: async ({ response }) => { 424 | const res = response 425 | if (res.data) res.data = toCamel(res.data) 426 | return res 427 | } 428 | } 429 | } 430 | 431 | return ( 432 | 433 | 434 | 435 | ) 436 | } 437 | 438 | ``` 439 | 440 | 441 | 442 | File Upload (FormData) 443 | ---------------------- 444 | 445 | This example shows how we can upload a file using `useFetch`. 446 | 447 | ```js 448 | import useFetch from 'use-http' 449 | 450 | const FileUploader = () => { 451 | const [file, setFile] = useState() 452 | 453 | const { post } = useFetch('https://example.com/upload') 454 | 455 | const uploadFile = async () => { 456 | const data = new FormData() 457 | data.append('file', file) 458 | if (file instanceof FormData) await post(data) 459 | } 460 | 461 | return ( 462 |
463 | {/* Drop a file onto the input below */} 464 | setFile(e.target.files[0])} /> 465 | 466 |
467 | ) 468 | } 469 | ``` 470 | 471 | Handling Different Response Types 472 | --------------------------------- 473 | 474 | This example shows how we can get `.json()`, `.text()`, `.formData()`, `.blob()`, `.arrayBuffer()`, and all the other [http response methods](https://developer.mozilla.org/en-US/docs/Web/API/Response#Methods). By default, `useFetch` 1st tries to call `response.json()` under the hood, if that fails it's backup is `response.text()`. If that fails, then you need a different response type which is where this comes in. 475 | 476 | ```js 477 | import useFetch from 'use-http' 478 | 479 | const App = () => { 480 | const [name, setName] = useState('') 481 | 482 | const { get, loading, error, response } = useFetch('http://example.com') 483 | 484 | const handleClick = async () => { 485 | await get('/users/1?name=true') // will return just the user's name 486 | const text = await response.text() 487 | setName(text) 488 | } 489 | 490 | return ( 491 | <> 492 | 493 | {error && error.messge} 494 | {loading && "Loading..."} 495 | {name &&
{name}
} 496 | 497 | ) 498 | } 499 | ``` 500 | 501 | 502 | 503 | 504 | Overwrite/Remove Options/Headers Set in Provider 505 | ------------------------------------------------ 506 | 507 | This example shows how to remove a header all together. Let's say you have ``, but for one api call, you don't want that header in your `useFetch` at all for one instance in your app. This would allow you to remove that. 508 | 509 | ```js 510 | import useFetch from 'use-http' 511 | 512 | const Todos = () => { 513 | // let's say for this request, you don't want the `Accept` header at all 514 | const { loading, error, data: todos = [] } = useFetch('/todos', globalOptions => { 515 | delete globalOptions.headers.Accept 516 | return globalOptions 517 | }, []) // onMount 518 | 519 | return ( 520 | <> 521 | {error && error.messge} 522 | {loading && "Loading..."} 523 | {todos &&
    {todos.map(todo =>
  • {todo.title}
  • )}
} 524 | 525 | ) 526 | } 527 | 528 | const App = () => { 529 | const options = { 530 | headers: { 531 | Accept: 'application/json' 532 | } 533 | } 534 | return ( 535 | 536 | } 537 | ``` 538 | 539 | 540 | 541 | Retries 542 | ------- 543 | 544 | In this example you can see how `retryOn` will retry on a status code of `305`, or if we choose the `retryOn()` function, it returns a boolean to decide if we will retry. With `retryDelay` we can either have a fixed delay, or a dynamic one by using `retryDelay()`. Make sure `retries` is set to at minimum `1` otherwise it won't retry the request. If `retries > 0` without `retryOn` then by default we always retry if there's an error or if `!response.ok`. If `retryOn: [400]` and `retries > 0` then we only retry on a response status of `400`, not on any generic network error. 545 | 546 | ```js 547 | import useFetch from 'use-http' 548 | 549 | const TestRetry = () => { 550 | const { response, get } = useFetch('https://httpbin.org/status/305', { 551 | // make sure `retries` is set otherwise it won't retry 552 | retries: 1, 553 | retryOn: [305], 554 | // OR 555 | retryOn: async ({ attempt, error, response }) => { 556 | // returns true or false to determine whether to retry 557 | return error || response && response.status >= 300 558 | }, 559 | 560 | retryDelay: 3000, 561 | // OR 562 | retryDelay: ({ attempt, error, response }) => { 563 | // exponential backoff 564 | return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000) 565 | // linear backoff 566 | return attempt * 1000 567 | } 568 | }) 569 | 570 | return ( 571 | <> 572 | 573 |
{JSON.stringify(response, null, 2)}
574 | 575 | ) 576 | } 577 | ``` 578 | 579 | 580 | 581 | GraphQL Query 582 | --------------- 583 | 584 | ```js 585 | 586 | const QUERY = ` 587 | query Todos($userID string!) { 588 | todos(userID: $userID) { 589 | id 590 | title 591 | } 592 | } 593 | ` 594 | 595 | function App() { 596 | const request = useFetch('http://example.com') 597 | 598 | const getTodosForUser = id => request.query(QUERY, { userID: id }) 599 | 600 | return ( 601 | <> 602 | 603 | {request.loading ? 'Loading...' :
{request.data}
} 604 | 605 | ) 606 | } 607 | ``` 608 | 609 | GraphQL Mutation 610 | ----------------- 611 | 612 | ```js 613 | 614 | const MUTATION = ` 615 | mutation CreateTodo($todoTitle string) { 616 | todo(title: $todoTitle) { 617 | id 618 | title 619 | } 620 | } 621 | ` 622 | 623 | function App() { 624 | const [todoTitle, setTodoTitle] = useState('') 625 | const request = useFetch('http://example.com') 626 | 627 | const createtodo = () => request.mutate(MUTATION, { todoTitle }) 628 | 629 | return ( 630 | <> 631 | setTodoTitle(e.target.value)} /> 632 | 633 | {request.loading ? 'Loading...' :
{request.data}
} 634 | 635 | ) 636 | } 637 | ``` 638 | 639 | `Provider` + `useMutation` and `useQuery` 640 | ========================================= 641 | 642 | The `Provider` allows us to set a default `url`, `options` (such as headers) and so on. 643 | 644 | useQuery (query for todos) 645 | -------------------------- 646 | 647 | ```js 648 | import { Provider, useQuery, useMutation } from 'use-http' 649 | 650 | function QueryComponent() { 651 | const request = useQuery` 652 | query Todos($userID string!) { 653 | todos(userID: $userID) { 654 | id 655 | title 656 | } 657 | } 658 | ` 659 | 660 | const getTodosForUser = id => request.query({ userID: id }) 661 | 662 | return ( 663 | <> 664 | 665 | {request.loading ? 'Loading...' :
{request.data}
} 666 | 667 | ) 668 | } 669 | ``` 670 | 671 | 672 | 673 | useMutation (add a new todo) 674 | ------------------- 675 | 676 | ```js 677 | function MutationComponent() { 678 | const [todoTitle, setTodoTitle] = useState('') 679 | 680 | const [data, loading, error, mutate] = useMutation` 681 | mutation CreateTodo($todoTitle string) { 682 | todo(title: $todoTitle) { 683 | id 684 | title 685 | } 686 | } 687 | ` 688 | 689 | const createTodo = () => mutate({ todoTitle }) 690 | 691 | return ( 692 | <> 693 | setTodoTitle(e.target.value)} /> 694 | 695 | {loading ? 'Loading...' :
{data}
} 696 | 697 | ) 698 | } 699 | ``` 700 | 701 | Adding the Provider 702 | ------------------- 703 | 704 | These props are defaults used in every request inside the ``. They can be overwritten individually 705 | 706 | ```js 707 | function App() { 708 | 709 | const options = { 710 | headers: { 711 | Authorization: 'Bearer jwt-asdfasdfasdf' 712 | } 713 | } 714 | 715 | return ( 716 | 717 | 718 | 719 | 720 | ) 721 | } 722 | ``` 723 | 724 | Hooks 725 | ======= 726 | 727 | | Option | Description | 728 | | --------------------- | ------------------ | 729 | | `useFetch` | The base hook | 730 | | `useQuery` | For making a GraphQL query | 731 | | `useMutation` | For making a GraphQL mutation | 732 | 733 | Options 734 | ======== 735 | 736 | This is exactly what you would pass to the normal js `fetch`, with a little extra. All these options can be passed to the ``, or directly to `useFetch`. If you have both in the `` and in `useFetch`, the `useFetch` options will overwrite the ones from the `` 737 | 738 | | Option | Description | Default | 739 | | --------------------- | --------------------------------------------------------------------------|------------- | 740 | | `cacheLife` | After a successful cache update, that cache data will become stale after this duration | `0` | 741 | | `cachePolicy` | These will be the same ones as Apollo's [fetch policies](https://www.apollographql.com/docs/react/api/react/hoc/#optionsfetchpolicy). Possible values are `cache-and-network`, `network-only`, `cache-only`, `no-cache`, `cache-first`. Currently only supports **`cache-first`** or **`no-cache`** | `cache-first` | 742 | | `data` | Allows you to set a default value for `data` | `undefined` | 743 | | `interceptors.request` | Allows you to do something before an http request is sent out. Useful for authentication if you need to refresh tokens a lot. | `undefined` | 744 | | `interceptors.response` | Allows you to do something after an http response is recieved. Useful for something like camelCasing the keys of the response. | `undefined` | 745 | | `loading` | Allows you to set default value for `loading` | `false` unless the last argument of `useFetch` is `[]` | 746 | | `onAbort` | Runs when the request is aborted. | empty function | 747 | | `onError` | Runs when the request get's an error. If retrying, it is only called on the last retry attempt. | empty function | 748 | | `onNewData` | Merges the current data with the incoming data. Great for pagination. | `(curr, new) => new` | 749 | | `onTimeout` | Called when the request times out. | empty function | 750 | | `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` | 751 | | `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` | 752 | | `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` | 753 | | `retries` | When a request fails or times out, retry the request this many times. By default it will not retry. | `0` | 754 | | `retryDelay` | You can retry with certain intervals i.e. 30 seconds `30000` or with custom logic (i.e. to increase retry intervals). | `1000` | 755 | | `retryOn` | You can retry on certain http status codes or have custom logic to decide whether to retry or not via a function. Make sure `retries > 0` otherwise it won't retry. | `[]` | 756 | | `suspense` | Enables Experimental React Suspense mode. [example](https://codesandbox.io/s/usefetch-suspense-i22wv) | `false` | 757 | | `timeout` | The request will be aborted/cancelled after this amount of time. This is also the interval at which `retries` will be made at. **in milliseconds**. If set to `0`, it will not timeout except for browser defaults. | `0` | 758 | 759 | ```jsx 760 | const options = { 761 | // accepts all `fetch` options such as headers, method, etc. 762 | 763 | // The time in milliseconds that cache data remains fresh. 764 | cacheLife: 0, 765 | 766 | // Cache responses to improve speed and reduce amount of requests 767 | // Only one request to the same endpoint will be initiated unless cacheLife expires for 'cache-first'. 768 | cachePolicy: 'cache-first' // 'no-cache' 769 | 770 | // set's the default for the `data` field 771 | data: [], 772 | 773 | // typically, `interceptors` would be added as an option to the `` 774 | interceptors: { 775 | request: async ({ options, url, path, route }) => { // `async` is not required 776 | return options // returning the `options` is important 777 | }, 778 | response: async ({ response }) => { 779 | // note: `response.data` is equivalent to `await response.json()` 780 | return response // returning the `response` is important 781 | } 782 | }, 783 | 784 | // set's the default for `loading` field 785 | loading: false, 786 | 787 | // called when aborting the request 788 | onAbort: () => {}, 789 | 790 | // runs when an error happens. 791 | onError: ({ error }) => {}, 792 | 793 | // this will allow you to merge the `data` for pagination. 794 | onNewData: (currData, newData) => { 795 | return [...currData, ...newData] 796 | }, 797 | 798 | // called when the request times out 799 | onTimeout: () => {}, 800 | 801 | // this will tell useFetch not to run the request if the list doesn't haveMore. (pagination) 802 | // i.e. if the last page fetched was < 15, don't run the request again 803 | perPage: 15, 804 | 805 | // Allows caching to persist after page refresh. Only supported in the Browser currently. 806 | persist: false, 807 | 808 | // this would basically call `await response.json()` 809 | // and set the `data` and `response.data` field to the output 810 | responseType: 'json', 811 | // OR can be an array. It's an array by default. 812 | // We will try to get the `data` by attempting to extract 813 | // it via these body interface methods, one by one in 814 | // this order. We skip `formData` because it's mostly used 815 | // for service workers. 816 | responseType: ['json', 'text', 'blob', 'arrayBuffer'], 817 | 818 | // amount of times it should retry before erroring out 819 | retries: 3, 820 | 821 | // The time between retries 822 | retryDelay: 10000, 823 | // OR 824 | // Can be a function which is used if we want change the time in between each retry 825 | retryDelay({ attempt, error, response }) { 826 | // exponential backoff 827 | return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000) 828 | // linear backoff 829 | return attempt * 1000 830 | }, 831 | 832 | 833 | // make sure `retries` is set otherwise it won't retry 834 | // can retry on certain http status codes 835 | retryOn: [503], 836 | // OR 837 | async retryOn({ attempt, error, response }) { 838 | // retry on any network error, or 4xx or 5xx status codes 839 | if (error !== null || response.status >= 400) { 840 | console.log(`retrying, attempt number ${attempt + 1}`); 841 | return true; 842 | } 843 | }, 844 | 845 | // enables experimental React Suspense mode 846 | suspense: true, // defaults to `false` 847 | 848 | // amount of time before the request get's canceled/aborted 849 | timeout: 10000, 850 | } 851 | 852 | useFetch(options) 853 | // OR 854 | 855 | ``` 856 | 857 | Who's using use-http? 858 | ===================== 859 | 860 | 874 | 875 | Browser Support 876 | =============== 877 | 878 | If you need support for IE, you will need to add additional polyfills. The React docs suggest [these polyfills][4], but from [this issue][2] we have found it to work fine with the [`react-app-polyfill`]. If you have any updates to this browser list, please submit a PR! 879 | 880 | | [IE / Edge]()
Edge | [Firefox]()
Firefox | [Chrome]()
Chrome | [Safari]()
Safari | [Opera]()
Opera | 881 | | --------- | --------- | --------- | --------- | --------- | 882 | | 12+ | last 2 versions| last 2 versions| last 2 versions| last 2 versions | 883 | 884 | Feature Requests/Ideas 885 | ====================== 886 | 887 | If you have feature requests, [submit an issue][1] to let us know what you would like to see! 888 | 889 | 922 | 923 | [1]: https://github.com/ava/use-http/issues/new?title=[Feature%20Request]%20YOUR_FEATURE_NAME 924 | [2]: https://github.com/ava/use-http/issues/93#issuecomment-600896722 925 | [3]: https://github.com/ava/use-http/raw/master/public/dog.png 926 | [4]: https://reactjs.org/docs/javascript-environment-requirements.html 927 | [`react-app-polyfill`]: https://www.npmjs.com/package/react-app-polyfill 928 | -------------------------------------------------------------------------------- /docs/_coverpage.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ![logo](https://github.com/alex-cory/use-http/raw/master/public/dog.png) 4 | 5 | > 🐶 React hook for making isomorphic http requests 6 | 7 | [GitHub](https://github.com/alex-cory/use-http) 8 | [Get Started](#/?id=managed-state-usage-⚠️) 9 | 10 | 11 | 12 | [github-watch-badge]: https://img.shields.io/github/watchers/alex-cory/use-http.svg?style=social 13 | [github-watch]: https://github.com/alex-cory/use-http/watchers 14 | [github-star-badge]: https://img.shields.io/github/stars/alex-cory/use-http.svg?style=social 15 | [github-star]: https://github.com/alex-cory/use-http/stargazers 16 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | useFetch 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 30 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Cory 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-http", 3 | "version": "1.0.25", 4 | "homepage": "https://use-http.com", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/alex-cory/use-http.git" 11 | }, 12 | "dependencies": { 13 | "urs": "^0.0.8", 14 | "use-ssr": "^1.0.24", 15 | "utility-types": "^3.10.0" 16 | }, 17 | "peerDependencies": { 18 | "react": "^16.13.1 || ^17.0.0 || ^18.0.0", 19 | "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" 20 | }, 21 | "devDependencies": { 22 | "@testing-library/react": "^10.0.2", 23 | "@testing-library/react-hooks": "^3.2.1", 24 | "@types/fetch-mock": "^7.3.2", 25 | "@types/jest": "^25.1.4", 26 | "@types/node": "^13.9.8", 27 | "@types/react": "^16.9.30", 28 | "@types/react-dom": "^16.9.5", 29 | "@typescript-eslint/eslint-plugin": "^2.26.0", 30 | "@typescript-eslint/parser": "^2.26.0", 31 | "convert-keys": "^1.3.4", 32 | "eslint": "^6.8.0", 33 | "eslint-config-standard": "^14.1.1", 34 | "eslint-plugin-import": "^2.20.2", 35 | "eslint-plugin-jest": "^23.8.2", 36 | "eslint-plugin-jest-formatting": "^1.2.0", 37 | "eslint-plugin-jsx-a11y": "^6.2.3", 38 | "eslint-plugin-node": "^11.1.0", 39 | "eslint-plugin-promise": "^4.2.1", 40 | "eslint-plugin-react": "^7.19.0", 41 | "eslint-plugin-react-hooks": "^3.0.0", 42 | "eslint-plugin-standard": "^4.0.1", 43 | "eslint-watch": "^6.0.1", 44 | "jest": "^25.2.4", 45 | "jest-fetch-mock": "^3.0.3", 46 | "jest-mock-console": "^1.0.0", 47 | "mockdate": "^2.0.5", 48 | "react": "^16.13.1", 49 | "react-dom": "^16.13.1", 50 | "react-hooks-testing-library": "^0.6.0", 51 | "react-test-renderer": "^16.13.1", 52 | "ts-jest": "^25.3.0", 53 | "typescript": "^3.8.3", 54 | "watch": "^1.0.2" 55 | }, 56 | "scripts": { 57 | "prepublishOnly": "yarn build:production && yarn build:production:esm # runs before publish", 58 | "build": "rm -rf dist/cjs && tsc --module CommonJS --outDir dist/cjs", 59 | "build:esm": "rm -rf dist/esm && tsc", 60 | "build:production": "yarn build -p tsconfig.production.json", 61 | "build:production:esm": "yarn build:esm -p tsconfig.production.json", 62 | "build:watch": "rm -rf dist && tsc -w --module CommonJS", 63 | "tsc": "tsc -p . --noEmit && tsc -p ./src/__tests__", 64 | "test:browser": "yarn tsc && jest -c ./config/jest.config.js --env=jsdom", 65 | "test:browser:watch": "yarn tsc && jest --watch -c ./config/jest.config.js --env=jsdom", 66 | "test:server": "yarn tsc && jest -c ./config/jest.config.js --env=node --testPathIgnorePatterns useFetch.test.tsx doFetchArgs.test.tsx", 67 | "test:server:watch": "yarn tsc && jest --watch -c ./config/jest.config.js --env=node --testPathIgnorePatterns useFetch.test.tsx doFetchArgs.test.tsx", 68 | "test:watch": "yarn test:browser:watch && yarn test:server:watch", 69 | "test": "yarn test:browser && yarn test:server", 70 | "clean": "npm prune; yarn cache clean; rm -rf ./node_modules package-lock.json yarn.lock; yarn", 71 | "lint": "eslint ./src/**/*.{ts,tsx}", 72 | "lint:fix": "npm run lint -- --fix", 73 | "lint:watch": "watch 'yarn lint'" 74 | }, 75 | "files": [ 76 | "dist" 77 | ], 78 | "keywords": [ 79 | "react hook", 80 | "react-hook", 81 | "use", 82 | "isomorphic", 83 | "use", 84 | "http", 85 | "fetch", 86 | "hook", 87 | "react", 88 | "useFetch", 89 | "fetch", 90 | "request", 91 | "axios", 92 | "react-use-fetch", 93 | "react-fetch-hook", 94 | "use-fetch", 95 | "suspense", 96 | "fetch data", 97 | "usefetch hook", 98 | "react-hooks-fetch", 99 | "react usefetch", 100 | "react hooks tutorial", 101 | "react-cache", 102 | "react custom hooks", 103 | "react-usefetch", 104 | "react hooks async", 105 | "react suspense", 106 | "use hooks", 107 | "react usefetch hook", 108 | "fetch-suspense", 109 | "async hook react", 110 | "react-hooks-fetch", 111 | "react hooks usefetch", 112 | "use fetch hook", 113 | "react fetch hook", 114 | "graphql", 115 | "mutation", 116 | "query", 117 | "useAxios", 118 | "use-axios", 119 | "use-superagent", 120 | "superagent", 121 | "apollo", 122 | "useGraphQL", 123 | "use-graphql" 124 | ] 125 | } -------------------------------------------------------------------------------- /public/abort-example-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/abort-example-1.gif -------------------------------------------------------------------------------- /public/abort-example-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/abort-example-2.gif -------------------------------------------------------------------------------- /public/apte-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/apte-logo.png -------------------------------------------------------------------------------- /public/ava-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/ava-logo.png -------------------------------------------------------------------------------- /public/dog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/dog.png -------------------------------------------------------------------------------- /public/microsoft-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/microsoft-logo.png -------------------------------------------------------------------------------- /public/mozilla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/mozilla.png -------------------------------------------------------------------------------- /public/watch-youtube-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava/use-http/6225d32f1ff40f46a98f65797015e8d86425de9e/public/watch-youtube-video.png -------------------------------------------------------------------------------- /src/ErrorBoundary.ts: -------------------------------------------------------------------------------- 1 | import { Component, ReactNode } from 'react' 2 | 3 | type ErrorBoundaryState = { 4 | hasError: boolean 5 | error: Error | null 6 | } 7 | 8 | // Error boundaries currently have to be classes. 9 | export default class ErrorBoundary extends Component<{ fallback: NonNullable|null }, ErrorBoundaryState> { 10 | state = { hasError: false, error: null } 11 | static getDerivedStateFromError(error: Record) { 12 | return { 13 | hasError: true, 14 | error 15 | } 16 | } 17 | 18 | render() { 19 | if (this.state.hasError) { 20 | console.error(this.state.error) 21 | return this.props.fallback 22 | } 23 | return this.props.children 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/FetchContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { FetchContextTypes } from './types' 3 | 4 | export const FetchContext = createContext({ 5 | url: '', 6 | options: {}, 7 | graphql: false // TODO: this will make it so useFetch(QUERY || MUTATION) will work 8 | }) 9 | 10 | export default FetchContext 11 | -------------------------------------------------------------------------------- /src/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, ReactElement } from 'react' 2 | import useSSR from 'use-ssr' 3 | import FetchContext from './FetchContext' 4 | import { FetchContextTypes, FetchProviderProps } from './types' 5 | 6 | export const Provider = ({ 7 | url, 8 | options, 9 | graphql = false, 10 | children 11 | }: FetchProviderProps): ReactElement => { 12 | const { isBrowser } = useSSR() 13 | 14 | const defaults = useMemo( 15 | (): FetchContextTypes => ({ 16 | url: url || (isBrowser ? window.location.origin : ''), 17 | options: options || {}, 18 | graphql // TODO: this will make it so useFetch(QUERY || MUTATION) will work 19 | }), 20 | [options, graphql, isBrowser, url] 21 | ) 22 | 23 | return ( 24 | {children} 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/__tests__/Provider.test.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from '..' 2 | 3 | /** 4 | * Many of these tests are dispersed throughout the other 5 | * tests. Take a look at useFetch.test.tsx 6 | */ 7 | describe('Provider - general', (): void => { 8 | it('should be defined/exist when imported', (): void => { 9 | expect(typeof Provider).toBe('function') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/__tests__/doFetchArgs.test.tsx: -------------------------------------------------------------------------------- 1 | import doFetchArgs from '../doFetchArgs' 2 | import { HTTPMethod } from '../types' 3 | import defaults from '../defaults' 4 | import useCache from '../useCache' 5 | 6 | describe('doFetchArgs: general usages', (): void => { 7 | it('should be defined', (): void => { 8 | expect(doFetchArgs).toBeDefined() 9 | }) 10 | 11 | it('should form the correct URL', async (): Promise => { 12 | const controller = new AbortController() 13 | const expectedRoute = '/test' 14 | const cache = useCache({ 15 | persist: false, 16 | cacheLife: defaults.cacheLife, 17 | cachePolicy: defaults.cachePolicy 18 | }) 19 | const { url, options } = await doFetchArgs( 20 | {}, 21 | HTTPMethod.POST, 22 | controller, 23 | defaults.cacheLife, 24 | cache, 25 | '', 26 | '', 27 | expectedRoute, 28 | {} 29 | ) 30 | expect(url).toBe(expectedRoute) 31 | expect(options).toStrictEqual({ 32 | body: '{}', 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | }, 36 | method: 'POST', 37 | signal: controller.signal 38 | }) 39 | }) 40 | 41 | it('should accept an array for the body of a request', async (): Promise => { 42 | const controller = new AbortController() 43 | const cache = useCache({ 44 | persist: false, 45 | cacheLife: defaults.cacheLife, 46 | cachePolicy: defaults.cachePolicy 47 | }) 48 | const { options, url } = await doFetchArgs( 49 | {}, 50 | HTTPMethod.POST, 51 | controller, 52 | defaults.cacheLife, 53 | cache, 54 | 'https://example.com', 55 | '', 56 | '/test', 57 | [] 58 | ) 59 | expect(url).toBe('https://example.com/test') 60 | expect(options).toStrictEqual({ 61 | body: '[]', 62 | headers: { 63 | 'Content-Type': 'application/json' 64 | }, 65 | method: 'POST', 66 | signal: controller.signal 67 | }) 68 | }) 69 | 70 | it('should correctly add `path` and `route` to the URL', async (): Promise => { 71 | const controller = new AbortController() 72 | const cache = useCache({ 73 | persist: false, 74 | cacheLife: defaults.cacheLife, 75 | cachePolicy: defaults.cachePolicy 76 | }) 77 | const { url } = await doFetchArgs( 78 | {}, 79 | HTTPMethod.POST, 80 | controller, 81 | defaults.cacheLife, 82 | cache, 83 | 'https://example.com', 84 | '/path', 85 | '/route', 86 | {} 87 | ) 88 | expect(url).toBe('https://example.com/path/route') 89 | }) 90 | 91 | it('should correctly modify the options with the request interceptor', async (): Promise => { 92 | const controller = new AbortController() 93 | const cache = useCache({ 94 | persist: false, 95 | cacheLife: defaults.cacheLife, 96 | cachePolicy: defaults.cachePolicy 97 | }) 98 | const interceptors = { 99 | async request({ options }: { options: any }) { 100 | options.headers.Authorization = 'Bearer test' 101 | return options 102 | } 103 | } 104 | const { options } = await doFetchArgs( 105 | {}, 106 | HTTPMethod.POST, 107 | controller, 108 | defaults.cacheLife, 109 | cache, 110 | undefined, 111 | '', 112 | '/test', 113 | {}, 114 | interceptors.request 115 | ) 116 | expect(options.headers).toHaveProperty('Authorization') 117 | expect(options).toStrictEqual({ 118 | body: '{}', 119 | headers: { 120 | 'Content-Type': 'application/json', 121 | Authorization: 'Bearer test' 122 | }, 123 | method: 'POST', 124 | signal: controller.signal 125 | }) 126 | }) 127 | }) 128 | 129 | describe('doFetchArgs: Errors', (): void => { 130 | it('should error if 1st and 2nd arg of doFetch are both objects', async (): Promise => { 131 | const controller = new AbortController() 132 | const cache = useCache({ 133 | persist: false, 134 | cacheLife: defaults.cacheLife, 135 | cachePolicy: defaults.cachePolicy 136 | }) 137 | // AKA, the last 2 arguments of doFetchArgs are both objects 138 | // try { 139 | // await doFetchArgs( 140 | // {}, 141 | // '', 142 | // '', 143 | // HTTPMethod.GET, 144 | // controller, 145 | // defaultCachePolicy, 146 | // cache, 147 | // {}, 148 | // {} 149 | // ) 150 | // } catch (err) { 151 | // expect(err.name).toBe('Invariant Violation') 152 | // expect(err.message).toBe('If first argument of get() is an object, you cannot have a 2nd argument. 😜') 153 | // } 154 | await expect( 155 | doFetchArgs( 156 | {}, 157 | HTTPMethod.GET, 158 | controller, 159 | defaults.cacheLife, 160 | cache, 161 | '', 162 | '', 163 | {}, 164 | {} 165 | ) 166 | ).rejects.toMatchObject({ 167 | name: 'Invariant Violation', 168 | message: 'If first argument of get() is an object, you cannot have a 2nd argument. 😜' 169 | }) 170 | }) 171 | 172 | it('should error if 1st and 2nd arg of doFetch are both arrays', async (): Promise => { 173 | const controller = new AbortController() 174 | const cache = useCache({ 175 | persist: false, 176 | cacheLife: defaults.cacheLife, 177 | cachePolicy: defaults.cachePolicy 178 | }) 179 | // AKA, the last 2 arguments of doFetchArgs are both arrays 180 | // try { 181 | // await doFetchArgs( 182 | // {}, 183 | // '', 184 | // '', 185 | // HTTPMethod.GET, 186 | // controller, 187 | // defaultCachePolicy, 188 | // cache, 189 | // [], 190 | // [] 191 | // ) 192 | // } catch (err) { 193 | // expect(err.name).toBe('Invariant Violation') 194 | // expect(err.message).toBe('If first argument of get() is an object, you cannot have a 2nd argument. 😜') 195 | // } 196 | await expect( 197 | doFetchArgs( 198 | {}, 199 | HTTPMethod.GET, 200 | controller, 201 | defaults.cacheLife, 202 | cache, 203 | '', 204 | '', 205 | [], 206 | [] 207 | ) 208 | ).rejects.toMatchObject({ 209 | name: 'Invariant Violation', 210 | message: 'If first argument of get() is an object, you cannot have a 2nd argument. 😜' 211 | }) 212 | }) 213 | 214 | // ADD TESTS: 215 | // - request.get('/test', {}) 216 | // - request.get('/test', '') 217 | }) 218 | -------------------------------------------------------------------------------- /src/__tests__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "include": [ 7 | "./**/*.ts", 8 | "./**/*.tsx" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/__tests__/useFetch-server.test.tsx: -------------------------------------------------------------------------------- 1 | import { useFetch } from '..' 2 | 3 | // import { FetchMock } from 'jest-fetch-mock' 4 | 5 | // const fetch = global.fetch as FetchMock 6 | 7 | import { renderHook } from '@testing-library/react-hooks' 8 | 9 | describe('useFetch - SERVER - basic usage', (): void => { 10 | it('should have loading === false when on server', async (): Promise< 11 | void 12 | > => { 13 | if (typeof window !== 'undefined') return 14 | const { result } = renderHook(() => useFetch('https://example.com')) 15 | expect(result.current.loading).toBe(false) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/__tests__/useFetchArgs.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks' 2 | import useFetchArgs from '../useFetchArgs' 3 | import defaults, { useFetchArgsDefaults } from '../defaults' 4 | import React, { ReactElement, ReactNode } from 'react' 5 | import { Provider } from '..' 6 | 7 | import { isServer } from '../utils' 8 | 9 | describe('useFetchArgs: general usages', (): void => { 10 | if (isServer) return 11 | 12 | const wrapper = ({ children }: { children?: ReactNode }): ReactElement => ( 13 | {children} 14 | ) 15 | 16 | it('should create custom options with `onMount: false` by default', (): void => { 17 | const { result } = renderHook((): any => 18 | useFetchArgs('https://example.com') 19 | ) 20 | expect(result.current).toEqual({ 21 | ...useFetchArgsDefaults, 22 | host: 'https://example.com', 23 | customOptions: { 24 | ...useFetchArgsDefaults.customOptions, 25 | } 26 | }) 27 | }) 28 | 29 | it('should create custom options with 1st arg as config object with `onMount: true`', (): void => { 30 | const { result } = renderHook((): any => 31 | useFetchArgs('https://example.com', []) // onMount === true 32 | ) 33 | expect(result.current).toEqual({ 34 | ...useFetchArgsDefaults, 35 | host: 'https://example.com', 36 | customOptions: { 37 | ...useFetchArgsDefaults.customOptions, 38 | loading: true, 39 | data: undefined 40 | }, 41 | dependencies: [] // onMount === true 42 | }) 43 | }) 44 | 45 | it('should create custom options handling Provider/Context properly', (): void => { 46 | const { result } = renderHook((): any => useFetchArgs(), { wrapper }) 47 | expect(result.current).toStrictEqual({ 48 | ...useFetchArgsDefaults, 49 | host: 'https://example.com', 50 | customOptions: { 51 | ...useFetchArgsDefaults.customOptions, 52 | } 53 | }) 54 | }) 55 | 56 | it('should overwrite `url` that is set in Provider/Context properly', (): void => { 57 | const { result } = renderHook( 58 | (): any => useFetchArgs('https://cool.com', []), // onMount === true 59 | { wrapper } 60 | ) 61 | expect(result.current).toStrictEqual({ 62 | ...useFetchArgsDefaults, 63 | host: 'https://cool.com', 64 | customOptions: { 65 | ...useFetchArgsDefaults.customOptions, 66 | loading: true, 67 | data: undefined 68 | }, 69 | dependencies: [] // onMount === true 70 | }) 71 | }) 72 | 73 | it('should set default data === []', (): void => { 74 | const { result } = renderHook( 75 | (): any => useFetchArgs({ data: [] }), 76 | { wrapper } 77 | ) 78 | expect(result.current).toStrictEqual({ 79 | ...useFetchArgsDefaults, 80 | host: 'https://example.com', 81 | customOptions: { 82 | ...useFetchArgsDefaults.customOptions, 83 | loading: false, 84 | data: [] 85 | } 86 | }) 87 | }) 88 | 89 | it('should have a default `url` if no URL is set in Provider', (): void => { 90 | if (isServer) return 91 | 92 | const wrapper2 = ({ children }: { children?: ReactNode }): ReactElement => ( 93 | {children} 94 | ) 95 | 96 | const { result } = renderHook((): any => useFetchArgs(), { wrapper: wrapper2 }) 97 | 98 | const expected = { 99 | ...useFetchArgsDefaults, 100 | host: 'http://localhost', 101 | customOptions: { 102 | ...useFetchArgsDefaults.customOptions, 103 | } 104 | } 105 | expect(result.current).toEqual(expected) 106 | }) 107 | 108 | it('should correctly execute request + response interceptors with Provider', async (): Promise => { 109 | const interceptors = { 110 | request(options: any) { 111 | options.headers.Authorization = 'Bearer test' 112 | return options 113 | }, 114 | response(response: any) { 115 | response.test = 'test' 116 | return response 117 | } 118 | } 119 | 120 | const wrapper2 = ({ children }: { children?: ReactNode }): ReactElement => ( 121 | {children} 122 | ) 123 | 124 | const { result } = renderHook( 125 | (): any => useFetchArgs(), 126 | { wrapper: wrapper2 } 127 | ) 128 | 129 | const { customOptions } = result.current 130 | const options = customOptions.interceptors.request({ headers: {} }) 131 | expect(options.headers).toHaveProperty('Authorization') 132 | expect(options).toStrictEqual({ 133 | headers: { 134 | Authorization: 'Bearer test' 135 | } 136 | }) 137 | const response = customOptions.interceptors.response({}) 138 | expect(response).toHaveProperty('test') 139 | expect(response).toEqual({ test: 'test' }) 140 | }) 141 | 142 | it('should correctly execute request + response interceptors', async (): Promise => { 143 | const interceptors = { 144 | request(options: any) { 145 | options.headers.Authorization = 'Bearer test' 146 | return options 147 | }, 148 | response(response: any) { 149 | response.test = 'test' 150 | return response 151 | } 152 | } 153 | 154 | const { result } = renderHook((): any => useFetchArgs('https://example.com', { interceptors })) 155 | 156 | const { customOptions } = result.current 157 | const options = customOptions.interceptors.request({ headers: {} }) 158 | expect(options.headers).toHaveProperty('Authorization') 159 | expect(options).toStrictEqual({ 160 | headers: { 161 | Authorization: 'Bearer test' 162 | } 163 | }) 164 | const response = customOptions.interceptors.response({}) 165 | expect(response).toHaveProperty('test') 166 | expect(response).toEqual({ test: 'test' }) 167 | }) 168 | 169 | it('should create custom options with `Content-Type: application/text`', (): void => { 170 | const options = { headers: { 'Content-Type': 'application/text' } } 171 | const { result } = renderHook((): any => useFetchArgs(options), { wrapper }) 172 | expect(result.current).toStrictEqual({ 173 | ...useFetchArgsDefaults, 174 | host: 'https://example.com', 175 | customOptions: { 176 | ...useFetchArgsDefaults.customOptions, 177 | }, 178 | requestInit: { 179 | ...options, 180 | headers: { 181 | ...defaults.headers, 182 | ...options.headers 183 | } 184 | } 185 | }) 186 | }) 187 | 188 | it('should create custom options and use the global options instead of defaults', (): void => { 189 | const options = { headers: { 'Content-Type': 'application/text' } } 190 | const wrapper = ({ children }: { children?: ReactNode }): ReactElement => ( 191 | {children} 192 | ) 193 | const { result } = renderHook((): any => useFetchArgs('http://localhost'), { wrapper }) 194 | expect(result.current).toStrictEqual({ 195 | ...useFetchArgsDefaults, 196 | host: 'http://localhost', 197 | customOptions: { 198 | ...useFetchArgsDefaults.customOptions, 199 | }, 200 | requestInit: { 201 | headers: { 202 | ...defaults.headers, 203 | ...options.headers 204 | } 205 | } 206 | }) 207 | }) 208 | 209 | it('should overwrite `Content-Type` that is set in Provider', (): void => { 210 | const options = { 211 | headers: { 212 | 'Content-Type': 'application/text' 213 | } 214 | } 215 | const wrapper = ({ children }: { children?: ReactNode }): ReactElement => ( 216 | {children} 217 | ) 218 | const overwriteProviderOptions = { 219 | headers: { 220 | 'Content-Type': 'multipart/form-data; boundary=something' 221 | } 222 | } 223 | const { result } = renderHook( 224 | (): any => useFetchArgs(overwriteProviderOptions), 225 | { wrapper } 226 | ) 227 | expect(result.current).toStrictEqual({ 228 | ...useFetchArgsDefaults, 229 | host: 'http://localhost', 230 | customOptions: { 231 | ...useFetchArgsDefaults.customOptions, 232 | }, 233 | requestInit: { 234 | ...overwriteProviderOptions, 235 | headers: { 236 | ...defaults.headers, 237 | ...overwriteProviderOptions.headers 238 | } 239 | } 240 | }) 241 | }) 242 | }) 243 | 244 | describe('useFetchArgs: Errors', (): void => { 245 | it('should error if 1st and 2nd arg are both objects', (): void => { 246 | const { result } = renderHook((): any => useFetchArgs({}, {})) 247 | expect(result.error.name).toBe('Invariant Violation') 248 | expect(result.error.message).toBe( 249 | 'You cannot have a 2nd parameter of useFetch as object when your first argument is an object.' 250 | ) 251 | }) 252 | 253 | // TODO 254 | it('should error if 1st and 2nd arg are both strings', (): void => { 255 | expect(typeof useFetchArgs).toBe('function') 256 | // const { result } = renderHook((): any => useFetchArgs('http://example.com', '?cool=sweet')) 257 | // expect(result.error.name).toBe('Invariant Violation') 258 | // expect(result.error.message).toBe('You cannot have a 2nd parameter of useFetch when your first argument is an object config.') 259 | }) 260 | 261 | // TODO 262 | it('should error if 1st arg is object and 2nd arg is string', (): void => { 263 | expect(typeof useFetchArgs).toBe('function') 264 | // const { result } = renderHook((): any => useFetchArgs({}, '?cool=sweet')) 265 | // expect(result.error.name).toBe('Invariant Violation') 266 | // expect(result.error.message).toBe('You cannot have a 2nd parameter of useFetch when your first argument is an object config.') 267 | }) 268 | }) 269 | -------------------------------------------------------------------------------- /src/__tests__/useMutation.test.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from '..' 2 | 3 | describe('useMutation - general', (): void => { 4 | it('should be defined/exist when imported', (): void => { 5 | expect(typeof useMutation).toBe('function') 6 | }) 7 | console.log('TODO: useMutation') 8 | }) 9 | -------------------------------------------------------------------------------- /src/__tests__/useQuery.test.tsx: -------------------------------------------------------------------------------- 1 | // import React, { useEffect } from "react" 2 | // import ReactDOM from 'react-dom' 3 | // import { 4 | // render, 5 | // cleanup, 6 | // waitForElement 7 | // } from '@testing-library/react' 8 | import { useQuery } from '..' 9 | 10 | // import { FetchMock } from "jest-fetch-mock" 11 | 12 | // const fetch = global.fetch as FetchMock 13 | 14 | // import { act } from "react-dom/test-utils" 15 | 16 | // Provider Tests ================================================= 17 | /** 18 | * Test Cases 19 | * Provider: 20 | * 1. URL only 21 | * 2. Options only 22 | * 3. graphql only 23 | * 4. URL and Options only 24 | * 5. URL and graphql only 25 | * 6. Options and graphql only 26 | * 7. URL and graphql and Options 27 | * useFetch: 28 | * A. const [data, loading, error, query] = useQuery('http://url.com', `grqphql query`) 29 | * B. const {data, loading, error, query} = useQuery('http://url.com', `grqphql query`) 30 | * C. const [data, loading, error, request] = useQuery(`grqphql query`) 31 | * D. const [data, loading, error, request] = useQuery`graphql query` 32 | */ 33 | describe('useQuery - general', (): void => { 34 | it('should be defined/exist when imported', (): void => { 35 | expect(typeof useQuery).toBe('function') 36 | }) 37 | console.log('TODO: useQuery') 38 | }) 39 | -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | import { Flatten, CachePolicies, UseFetchArgsReturn } from './types' 2 | import { isObject } from './utils' 3 | 4 | 5 | export const useFetchArgsDefaults: UseFetchArgsReturn = { 6 | host: '', 7 | path: undefined, 8 | customOptions: { 9 | cacheLife: 0, 10 | cachePolicy: CachePolicies.CACHE_FIRST, 11 | interceptors: {}, 12 | onAbort: () => { /* do nothing */ }, 13 | onError: () => { /* do nothing */ }, 14 | onNewData: (currData: any, newData: any) => newData, 15 | onTimeout: () => { /* do nothing */ }, 16 | perPage: 0, 17 | persist: false, 18 | responseType: ['json', 'text', 'blob', 'arrayBuffer'], 19 | retries: 0, 20 | retryDelay: 1000, 21 | retryOn: [], 22 | suspense: false, 23 | timeout: 0, 24 | // defaults 25 | data: undefined, 26 | loading: false 27 | }, 28 | requestInit: { 29 | headers: { 30 | Accept: 'application/json, text/plain, */*' 31 | } 32 | }, 33 | dependencies: undefined 34 | } 35 | 36 | export default Object.entries(useFetchArgsDefaults).reduce((acc, [key, value]) => { 37 | if (isObject(value)) return { ...acc, ...value } 38 | return { ...acc, [key]: value } 39 | }, {} as Flatten) 40 | -------------------------------------------------------------------------------- /src/doFetchArgs.ts: -------------------------------------------------------------------------------- 1 | import { HTTPMethod, Interceptors, ValueOf, DoFetchArgs, Cache } from './types' 2 | import { invariant, isServer, isString, isBodyObject, addSlash } from './utils' 3 | 4 | const { GET } = HTTPMethod 5 | 6 | export default async function doFetchArgs( 7 | initialOptions: RequestInit, 8 | method: HTTPMethod, 9 | controller: AbortController, 10 | cacheLife: number, 11 | cache: Cache, 12 | host?: string, 13 | path?: string, 14 | routeOrBody?: string | BodyInit | object, 15 | bodyAs2ndParam?: BodyInit | object, 16 | requestInterceptor?: ValueOf> 17 | ): Promise { 18 | invariant( 19 | !(isBodyObject(routeOrBody) && isBodyObject(bodyAs2ndParam)), 20 | `If first argument of ${method.toLowerCase()}() is an object, you cannot have a 2nd argument. 😜` 21 | ) 22 | invariant( 23 | !(method === GET && isBodyObject(routeOrBody)), 24 | 'You can only have query params as 1st argument of request.get()' 25 | ) 26 | invariant( 27 | !(method === GET && bodyAs2ndParam !== undefined), 28 | 'You can only have query params as 1st argument of request.get()' 29 | ) 30 | 31 | const route = ((): string => { 32 | if (!isServer && routeOrBody instanceof URLSearchParams) return `?${routeOrBody}` 33 | if (isString(routeOrBody)) return routeOrBody as string 34 | return '' 35 | })() 36 | 37 | const url = `${host}${addSlash(path, host)}${addSlash(route)}` 38 | 39 | const body = ((): BodyInit | null => { 40 | // FormData instanceof check should go first, because React Native's FormData implementation 41 | // is indistinguishable from plain object when using isBodyObject check 42 | if (routeOrBody instanceof FormData) return routeOrBody 43 | if (isBodyObject(routeOrBody)) return JSON.stringify(routeOrBody) 44 | if ( 45 | !isServer && 46 | ((bodyAs2ndParam as any) instanceof FormData || 47 | (bodyAs2ndParam as any) instanceof URLSearchParams) 48 | ) return bodyAs2ndParam as any 49 | if (isBodyObject(bodyAs2ndParam) || isString(bodyAs2ndParam)) return JSON.stringify(bodyAs2ndParam) 50 | if (isBodyObject(initialOptions.body) || isString(bodyAs2ndParam)) return JSON.stringify(initialOptions.body) 51 | return null 52 | })() 53 | 54 | const headers = ((): HeadersInit | null => { 55 | const contentType = ((initialOptions.headers || {}) as any)['Content-Type'] 56 | const shouldAddContentType = !!contentType || [HTTPMethod.POST, HTTPMethod.PUT, HTTPMethod.PATCH].includes(method) && !(body instanceof FormData) 57 | const headers: any = { ...initialOptions.headers } 58 | if (shouldAddContentType) { 59 | // default content types http://bit.ly/2N2ovOZ 60 | // Accept: 'application/json', 61 | // roughly, should only add for POST and PUT http://bit.ly/2NJNt3N 62 | // unless specified by the user 63 | headers['Content-Type'] = contentType || 'application/json' 64 | } else if (Object.keys(headers).length === 0) { 65 | return null 66 | } 67 | return headers 68 | })() 69 | 70 | const options = await (async (): Promise => { 71 | const opts: RequestInit = { 72 | ...initialOptions, 73 | method, 74 | signal: controller.signal 75 | } 76 | 77 | if (headers !== null) { 78 | opts.headers = headers 79 | } else { 80 | delete opts.headers 81 | } 82 | 83 | if (body !== null) opts.body = body 84 | 85 | if (requestInterceptor) { 86 | const interceptor = await requestInterceptor({ options: opts, url: host, path, route }) 87 | return interceptor as any 88 | } 89 | return opts 90 | })() 91 | 92 | // TODO: if the body is a file, and this is a large file, it might exceed the size 93 | // limit of the key size. Potential solution: base64 the body 94 | // used to tell if a request has already been made 95 | const responseID = Object.entries({ url, method, body: options.body || '' }) 96 | .map(([key, value]) => `${key}:${value}`).join('||') 97 | 98 | return { 99 | url, 100 | options, 101 | response: { 102 | isCached: await cache.has(responseID), 103 | id: responseID, 104 | cached: await cache.get(responseID) as Response | undefined 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './useFetch' 2 | export * from './useFetch' 3 | export * from './useMutation' 4 | export * from './useQuery' 5 | export * from './Provider' 6 | export * from './FetchContext' 7 | export * from './types' 8 | export * from './useFetchArgs' 9 | -------------------------------------------------------------------------------- /src/storage/__tests__/localStorage.test.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '../../types' 2 | import getLocalStorage from '../localStorage' 3 | 4 | import mockdate from 'mockdate' 5 | 6 | const localStorageMock = (function() { 7 | const store: any = {} 8 | 9 | return { 10 | store, 11 | getItem: function(key: string) { 12 | return store[key] || null 13 | }, 14 | setItem: function(key: string, value: string) { 15 | store[key] = value.toString() 16 | } 17 | } 18 | })() 19 | 20 | Object.defineProperty(global, 'localStorage', { 21 | value: localStorageMock 22 | }) 23 | 24 | describe('localStorage cache', () => { 25 | let cache: Cache 26 | const cacheLife = 3600000 // an hour 27 | 28 | beforeEach(() => { 29 | cache = getLocalStorage({ cacheLife }) 30 | }) 31 | 32 | afterAll((): void => { 33 | mockdate.reset() 34 | }) 35 | 36 | beforeAll((): void => { 37 | mockdate.set('2020-01-01T00:00:00.000Z') 38 | }) 39 | 40 | it('stores and recreates response', async () => { 41 | const body = 'response body' 42 | const status = 200 43 | const statusText = 'OK' 44 | const headers = new Headers({ 'content-type': 'application/json' }) 45 | const response = new Response( 46 | body, 47 | { 48 | status, 49 | statusText, 50 | headers 51 | } 52 | ) 53 | const responseID = 'aID' 54 | 55 | await cache.set(responseID, response) 56 | const received = await cache.get(responseID) as Response 57 | 58 | expect(await received.text()).toEqual(body) 59 | expect(received.ok).toBeTruthy() 60 | expect(received.status).toEqual(status) 61 | expect(received.statusText).toEqual(statusText) 62 | expect(received.headers.get('content-type')).toEqual('application/json') 63 | }) 64 | 65 | it('clears cache on expiration', async () => { 66 | const body = 'response body' 67 | const status = 200 68 | const statusText = 'OK' 69 | const headers = new Headers({ 'content-type': 'application/json' }) 70 | const response = new Response( 71 | body, 72 | { 73 | status, 74 | statusText, 75 | headers 76 | } 77 | ) 78 | const responseID = 'aID' 79 | 80 | await cache.set(responseID, response) 81 | mockdate.set('2020-01-01T02:00:00.000Z') 82 | await cache.get(responseID) 83 | 84 | expect(localStorageMock.store.useHTTPcache).toEqual('{}') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/storage/localStorage.ts: -------------------------------------------------------------------------------- 1 | import { serializeResponse } from '../utils' 2 | import { Cache } from '../types' 3 | 4 | const cacheName = 'useHTTPcache' 5 | 6 | const getCache = () => { 7 | try { 8 | return JSON.parse(localStorage.getItem(cacheName) || '{}') 9 | } catch (err) { 10 | localStorage.removeItem(cacheName) 11 | return {} 12 | } 13 | } 14 | const getLocalStorage = ({ cacheLife }: { cacheLife: number }): Cache => { 15 | const remove = async (...responseIDs: string[]) => { 16 | const cache = getCache() 17 | responseIDs.forEach(id => delete cache[id]) 18 | localStorage.setItem(cacheName, JSON.stringify(cache)) 19 | } 20 | 21 | const isExpired = (responseID: string) => { 22 | const cache = getCache() 23 | const { expiration, response } = (cache[responseID] || {}) 24 | const expired = expiration > 0 && expiration < Date.now() 25 | if (expired) remove(responseID) 26 | return expired || !response 27 | } 28 | 29 | const has = async (responseID: string) => !isExpired(responseID) 30 | 31 | const get = async (responseID: string) => { 32 | if (isExpired(responseID)) return 33 | const cache = getCache() 34 | const { body, headers, status, statusText } = cache[responseID].response 35 | return new Response(body, { 36 | status, 37 | statusText, 38 | headers: new Headers(headers || {}) 39 | }) 40 | } 41 | 42 | const set = async (responseID: string, response: Response): Promise => { 43 | const cache = getCache() 44 | cache[responseID] = { 45 | response: await serializeResponse(response), 46 | expiration: Date.now() + cacheLife 47 | } 48 | localStorage.setItem(cacheName, JSON.stringify(cache)) 49 | } 50 | 51 | const clear = async () => { 52 | localStorage.setItem(cacheName, JSON.stringify({})) 53 | } 54 | 55 | return Object.defineProperties(getCache(), { 56 | get: { value: get, writable: false }, 57 | set: { value: set, writable: false }, 58 | has: { value: has, writable: false }, 59 | delete: { value: remove, writable: false }, 60 | clear: { value: clear, writable: false } 61 | }) 62 | } 63 | 64 | export default getLocalStorage 65 | -------------------------------------------------------------------------------- /src/storage/memoryStorage.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '../types' 2 | 3 | let inMemoryStorage: any = {} 4 | const getMemoryStorage = ({ cacheLife }: { cacheLife: number }): Cache => { 5 | const remove = async (...responseIDs: string[]) => { 6 | for (const responseID of responseIDs) { 7 | delete inMemoryStorage[responseID] 8 | delete inMemoryStorage[`${responseID}:ts`] 9 | } 10 | } 11 | 12 | const isExpired = (responseID: string) => { 13 | const expiration = inMemoryStorage[`${responseID}:ts`] 14 | const expired = expiration > 0 && expiration < Date.now() 15 | if (expired) remove(responseID) 16 | return expired || !inMemoryStorage[responseID] 17 | } 18 | 19 | const get = async (responseID: string) => { 20 | if (isExpired(responseID)) return 21 | return inMemoryStorage[responseID] as Response 22 | } 23 | 24 | const set = async (responseID: string, res: Response) => { 25 | inMemoryStorage[responseID] = res 26 | inMemoryStorage[`${responseID}:ts`] = cacheLife > 0 ? Date.now() + cacheLife : 0 27 | } 28 | 29 | const has = async (responseID: string) => !isExpired(responseID) 30 | 31 | const clear = async () => { 32 | inMemoryStorage = {} 33 | } 34 | 35 | return Object.defineProperties(inMemoryStorage, { 36 | get: { value: get, writable: false, configurable: true }, 37 | set: { value: set, writable: false, configurable: true }, 38 | has: { value: has, writable: false, configurable: true }, 39 | delete: { value: remove, writable: false, configurable: true }, 40 | clear: { value: clear, writable: false, configurable: true } 41 | }) 42 | } 43 | 44 | export default getMemoryStorage 45 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { FunctionKeys } from 'utility-types' 3 | 4 | export enum HTTPMethod { 5 | DELETE = 'DELETE', 6 | GET = 'GET', 7 | HEAD = 'HEAD', 8 | OPTIONS = 'OPTIONS', 9 | PATCH = 'PATCH', 10 | POST = 'POST', 11 | PUT = 'PUT', 12 | CONNECT = 'CONNECT', 13 | TRACE = 'TRACE' 14 | } 15 | 16 | // https://www.apollographql.com/docs/react/api/react/hoc/#optionsfetchpolicy 17 | export enum CachePolicies { 18 | /** 19 | * This is the default value where we always try reading data 20 | * from your cache first. If all the data needed to fulfill 21 | * your query is in the cache then that data will be returned. 22 | * useFetch will only fetch from the network if a cached result 23 | * is not available. This fetch policy aims to minimize the number 24 | * of network requests sent when rendering your component. 25 | */ 26 | CACHE_FIRST = 'cache-first', 27 | /** 28 | * This fetch policy will have useFetch first trying to read data 29 | * from your cache. If all the data needed to fulfill your query 30 | * is in the cache then that data will be returned. However, 31 | * regardless of whether or not the full data is in your cache 32 | * this fetchPolicy will always execute query with the network 33 | * interface unlike cache-first which will only execute your query 34 | * if the query data is not in your cache. This fetch policy optimizes 35 | * for users getting a quick response while also trying to keep 36 | * cached data consistent with your server data at the cost of extra 37 | * network requests. 38 | */ 39 | CACHE_AND_NETWORK = 'cache-and-network', // not implemented 40 | /** 41 | * This fetch policy will never return your initial data from the 42 | * cache. Instead it will always make a request using your network 43 | * interface to the server. This fetch policy optimizes for data 44 | * consistency with the server, but at the cost of an instant response 45 | * to the user when one is available. 46 | */ 47 | NETWORK_ONLY = 'network-only', // not implemented 48 | /** 49 | * This fetch policy will never execute a query using your network 50 | * interface. Instead it will always try reading from the cache. If the 51 | * data for your query does not exist in the cache then an error will be 52 | * thrown. This fetch policy allows you to only interact with data in 53 | * your local client cache without making any network requests which 54 | * keeps your component fast, but means your local data might not be 55 | * consistent with what is on the server. 56 | */ 57 | CACHE_ONLY = 'cache-only', // not implemented 58 | /** 59 | * This fetch policy will never return your initial data from the cache. 60 | * Instead it will always make a request using your network interface to 61 | * the server. Unlike the network-only policy, it also will not write 62 | * any data to the cache after the query completes. 63 | */ 64 | NO_CACHE = 'no-cache', // not implemented 65 | EXACT_CACHE_AND_NETWORK = 'exact-cache-and-network', // not implemented 66 | } 67 | 68 | export interface DoFetchArgs { 69 | url: string 70 | options: RequestInit 71 | response: { 72 | isCached: boolean 73 | id: string 74 | cached?: Response 75 | } 76 | } 77 | 78 | export interface FetchContextTypes { 79 | url: string 80 | options: IncomingOptions 81 | graphql?: boolean 82 | } 83 | 84 | export interface FetchProviderProps { 85 | url?: string 86 | options?: IncomingOptions 87 | graphql?: boolean 88 | children: ReactNode 89 | } 90 | 91 | export type BodyOnly = (body: BodyInit | object) => Promise 92 | 93 | export type RouteOnly = (route: string) => Promise 94 | 95 | export type RouteAndBodyOnly = ( 96 | route: string, 97 | body: BodyInit | object, 98 | ) => Promise 99 | 100 | export type RouteOrBody = string | BodyInit | object 101 | export type UFBody = BodyInit | object 102 | export type RetryOpts = { attempt: number, error?: Error, response?: Response } 103 | 104 | export type NoArgs = () => Promise 105 | 106 | export type FetchData = ( 107 | routeOrBody?: string | BodyInit | object, 108 | body?: BodyInit | object, 109 | ) => Promise 110 | 111 | export type RequestInitJSON = RequestInit & { 112 | headers: { 113 | 'Content-Type': string 114 | } 115 | } 116 | 117 | export interface ReqMethods { 118 | get: (route?: string) => Promise 119 | post: FetchData 120 | patch: FetchData 121 | put: FetchData 122 | del: FetchData 123 | delete: FetchData 124 | query: (query: string, variables?: BodyInit | object) => Promise 125 | mutate: (mutation: string, variables?: BodyInit | object) => Promise 126 | abort: () => void 127 | } 128 | 129 | export interface Data { 130 | data: TData | undefined 131 | } 132 | 133 | export interface ReqBase { 134 | data: TData | undefined 135 | loading: boolean 136 | error: Error | undefined 137 | cache: Cache 138 | } 139 | 140 | export interface Res extends Response { 141 | data?: TData | undefined 142 | } 143 | 144 | export type Req = ReqMethods & ReqBase 145 | 146 | export type UseFetchArgs = [(string | IncomingOptions | OverwriteGlobalOptions)?, (IncomingOptions | OverwriteGlobalOptions | any[])?, any[]?] 147 | 148 | export type UseFetchArrayReturn = [ 149 | Req, 150 | Res, 151 | boolean, 152 | Error, 153 | ] 154 | 155 | export type UseFetchObjectReturn = ReqBase & 156 | ReqMethods & { 157 | request: Req 158 | response: Res 159 | } 160 | 161 | export type UseFetch = UseFetchArrayReturn & 162 | UseFetchObjectReturn 163 | 164 | export type Interceptors = { 165 | request?: ({ options, url, path, route }: { options: RequestInit, url?: string, path?: string, route?: string }) => Promise | RequestInit 166 | response?: ({ response }: { response: Res, request: RequestInit }) => Promise> 167 | } 168 | 169 | // this also holds the response keys. It mimics js Map 170 | export type Cache = { 171 | get: (name: string) => Promise 172 | set: (name: string, data: Response) => Promise 173 | has: (name: string) => Promise 174 | delete: (...names: string[]) => Promise 175 | clear: () => void 176 | } 177 | 178 | export interface CustomOptions { 179 | cacheLife: number 180 | cachePolicy: CachePolicies 181 | data: any 182 | interceptors: Interceptors 183 | loading: boolean 184 | onAbort: () => void 185 | onError: OnError 186 | onNewData: (currData: any, newData: any) => any 187 | onTimeout: () => void 188 | persist: boolean 189 | perPage: number 190 | responseType: ResponseType 191 | retries: number 192 | retryOn: RetryOn 193 | retryDelay: RetryDelay 194 | suspense: boolean 195 | timeout: number 196 | } 197 | 198 | // these are the possible options that can be passed 199 | export type IncomingOptions = Partial & 200 | Omit & { body?: BodyInit | object | null } 201 | // these options have `context` and `defaults` applied so 202 | // the values should all be filled 203 | export type Options = CustomOptions & 204 | Omit & { body?: BodyInit | object | null } 205 | 206 | export type OverwriteGlobalOptions = (options: Options) => Options 207 | 208 | export type RetryOn = (({ attempt, error, response }: RetryOpts) => Promise) | number[] 209 | export type RetryDelay = (({ attempt, error, response }: RetryOpts) => number) | number 210 | 211 | export type BodyInterfaceMethods = Exclude, 'body' | 'bodyUsed' | 'formData'> 212 | export type ResponseType = BodyInterfaceMethods | BodyInterfaceMethods[] 213 | 214 | export type OnError = ({ error }: { error: Error }) => void 215 | 216 | export type UseFetchArgsReturn = { 217 | host: string 218 | path?: string 219 | customOptions: { 220 | cacheLife: number 221 | cachePolicy: CachePolicies 222 | interceptors: Interceptors 223 | onAbort: () => void 224 | onError: OnError 225 | onNewData: (currData: any, newData: any) => any 226 | onTimeout: () => void 227 | perPage: number 228 | persist: boolean 229 | responseType: ResponseType 230 | retries: number 231 | retryDelay: RetryDelay 232 | retryOn: RetryOn | undefined 233 | suspense: boolean 234 | timeout: number 235 | // defaults 236 | loading: boolean 237 | data?: any 238 | } 239 | requestInit: RequestInit 240 | dependencies?: any[] 241 | } 242 | 243 | /** 244 | * Helpers 245 | */ 246 | export type ValueOf = T[keyof T] 247 | 248 | export type NonObjectKeysOf = { 249 | [K in keyof T]: T[K] extends Array ? K : T[K] extends object ? never : K 250 | }[keyof T] 251 | 252 | export type ObjectValuesOf> = Exclude< 253 | Exclude, object>, never>, 254 | Array 255 | > 256 | 257 | export type UnionToIntersection = (U extends any 258 | ? (k: U) => void 259 | : never) extends ((k: infer I) => void) 260 | ? I 261 | : never 262 | 263 | export type Flatten = Pick> & UnionToIntersection> 264 | -------------------------------------------------------------------------------- /src/useCache.ts: -------------------------------------------------------------------------------- 1 | import useSSR from 'use-ssr' 2 | import { invariant } from './utils' 3 | import { Cache, CachePolicies } from './types' 4 | 5 | import getLocalStorage from './storage/localStorage' 6 | import getMemoryStorage from './storage/memoryStorage' 7 | 8 | const { NETWORK_ONLY, NO_CACHE } = CachePolicies 9 | /** 10 | * Eventually, this will be replaced by use-react-storage, so 11 | * having this as a hook allows us to have minimal changes in 12 | * the future when switching over. 13 | */ 14 | type UseCacheArgs = { persist: boolean, cacheLife: number, cachePolicy: CachePolicies } 15 | const useCache = ({ persist, cacheLife, cachePolicy }: UseCacheArgs): Cache => { 16 | const { isNative, isServer } = useSSR() 17 | invariant(!(isServer && persist), 'There is no persistent storage on the Server currently! 🙅‍♂️') 18 | invariant(!(isNative && persist), 'React Native support for persistent cache is not yet implemented. 🙅‍♂️') 19 | invariant(!(persist && [NO_CACHE, NETWORK_ONLY].includes(cachePolicy)), `You cannot use option 'persist' with cachePolicy: ${cachePolicy} 🙅‍♂️`) 20 | 21 | // right now we're not worrying about react-native 22 | if (persist) return getLocalStorage({ cacheLife: cacheLife || (24 * 3600000) }) 23 | return getMemoryStorage({ cacheLife }) 24 | } 25 | 26 | export default useCache 27 | -------------------------------------------------------------------------------- /src/useFetch.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useRef, useReducer, useMemo } from 'react' 2 | import useSSR from 'use-ssr' 3 | import useRefState from 'urs' 4 | import { 5 | HTTPMethod, 6 | UseFetch, 7 | ReqMethods, 8 | Req, 9 | Res, 10 | UseFetchArrayReturn, 11 | UseFetchObjectReturn, 12 | UseFetchArgs, 13 | CachePolicies, 14 | FetchData, 15 | NoArgs, 16 | RouteOrBody, 17 | UFBody, 18 | RetryOpts 19 | } from './types' 20 | import useFetchArgs from './useFetchArgs' 21 | import doFetchArgs from './doFetchArgs' 22 | import { invariant, tryGetData, toResponseObject, useDeepCallback, isFunction, sleep, makeError } from './utils' 23 | import useCache from './useCache' 24 | 25 | const { CACHE_FIRST } = CachePolicies 26 | 27 | 28 | function useFetch(...args: UseFetchArgs): UseFetch { 29 | const { host, path, customOptions, requestInit, dependencies } = useFetchArgs(...args) 30 | const { 31 | cacheLife, 32 | cachePolicy, // 'cache-first' by default 33 | interceptors, 34 | onAbort, 35 | onError, 36 | onNewData, 37 | onTimeout, 38 | perPage, 39 | persist, 40 | responseType, 41 | retries, 42 | retryDelay, 43 | retryOn, 44 | suspense, 45 | timeout, 46 | ...defaults 47 | } = customOptions 48 | 49 | const cache = useCache({ persist, cacheLife, cachePolicy }) 50 | 51 | const { isServer } = useSSR() 52 | 53 | const controller = useRef() 54 | const res = useRef>({} as Res) 55 | const data = useRef(defaults.data) 56 | const timedout = useRef(false) 57 | const attempt = useRef(0) 58 | const error = useRef() 59 | const hasMore = useRef(true) 60 | const suspenseStatus = useRef('pending') 61 | const suspender = useRef>() 62 | const mounted = useRef(false) 63 | 64 | const [loading, setLoading] = useRefState(defaults.loading) 65 | const forceUpdate = useReducer(() => ({}), [])[1] 66 | 67 | const makeFetch = useDeepCallback((method: HTTPMethod): FetchData => { 68 | 69 | const doFetch = async (routeOrBody?: RouteOrBody, body?: UFBody): Promise => { 70 | if (isServer) return // for now, we don't do anything on the server 71 | controller.current = new AbortController() 72 | controller.current.signal.onabort = onAbort 73 | const theController = controller.current 74 | 75 | const { url, options, response } = await doFetchArgs( 76 | requestInit, 77 | method, 78 | theController, 79 | cacheLife, 80 | cache, 81 | host, 82 | path, 83 | routeOrBody, 84 | body, 85 | interceptors.request 86 | ) 87 | 88 | error.current = undefined 89 | 90 | // don't perform the request if there is no more data to fetch (pagination) 91 | if (perPage > 0 && !hasMore.current && !error.current) return data.current 92 | 93 | if (!suspense) setLoading(true) 94 | 95 | const timer = timeout && setTimeout(() => { 96 | timedout.current = true 97 | theController.abort() 98 | if (onTimeout) onTimeout() 99 | }, timeout) 100 | 101 | let newData 102 | let newRes 103 | 104 | try { 105 | if (response.isCached && cachePolicy === CACHE_FIRST) { 106 | newRes = response.cached as Response 107 | } else { 108 | newRes = (await fetch(url, options)).clone() 109 | } 110 | res.current = newRes.clone() 111 | 112 | newData = await tryGetData(newRes, defaults.data, responseType) 113 | res.current.data = onNewData(data.current, newData) 114 | 115 | res.current = interceptors.response ? await interceptors.response({ response: res.current, request: requestInit }) : res.current 116 | invariant('data' in res.current, 'You must have `data` field on the Response returned from your `interceptors.response`') 117 | data.current = res.current.data as TData 118 | 119 | const opts = { attempt: attempt.current, response: newRes } 120 | const shouldRetry = ( 121 | // if we just have `retries` set with NO `retryOn` then 122 | // automatically retry on fail until attempts run out 123 | !isFunction(retryOn) && Array.isArray(retryOn) && retryOn.length < 1 && newRes?.ok === false 124 | // otherwise only retry when is specified 125 | || Array.isArray(retryOn) && retryOn.includes(newRes.status) 126 | || isFunction(retryOn) && await (retryOn as Function)(opts) 127 | ) && retries > 0 && retries > attempt.current 128 | 129 | if (shouldRetry) { 130 | const theData = await retry(opts, routeOrBody, body) 131 | return theData 132 | } 133 | 134 | if (cachePolicy === CACHE_FIRST && !response.isCached) { 135 | await cache.set(response.id, newRes.clone()) 136 | } 137 | 138 | if (Array.isArray(data.current) && !!(data.current.length % perPage)) hasMore.current = false 139 | } catch (err) { 140 | if (attempt.current >= retries && timedout.current) error.current = makeError('AbortError', 'Timeout Error') 141 | const opts = { attempt: attempt.current, error: err } 142 | const shouldRetry = ( 143 | // if we just have `retries` set with NO `retryOn` then 144 | // automatically retry on fail until attempts run out 145 | !isFunction(retryOn) && Array.isArray(retryOn) && retryOn.length < 1 146 | // otherwise only retry when is specified 147 | || isFunction(retryOn) && await (retryOn as Function)(opts) 148 | ) && retries > 0 && retries > attempt.current 149 | 150 | if (shouldRetry) { 151 | const theData = await retry(opts, routeOrBody, body) 152 | return theData 153 | } 154 | if (err.name !== 'AbortError') { 155 | error.current = err 156 | } 157 | 158 | } finally { 159 | timedout.current = false 160 | if (timer) clearTimeout(timer) 161 | controller.current = undefined 162 | } 163 | 164 | if (newRes && !newRes.ok && !error.current) error.current = makeError(newRes.status, newRes.statusText) 165 | if (!suspense) setLoading(false) 166 | if (attempt.current === retries) attempt.current = 0 167 | if (error.current) onError({ error: error.current }) 168 | 169 | return data.current 170 | } // end of doFetch() 171 | 172 | const retry = async (opts: RetryOpts, routeOrBody?: RouteOrBody, body?: UFBody) => { 173 | const delay = (isFunction(retryDelay) ? (retryDelay as Function)(opts) : retryDelay) as number 174 | if (!(Number.isInteger(delay) && delay >= 0)) { 175 | console.error('retryDelay must be a number >= 0! If you\'re using it as a function, it must also return a number >= 0.') 176 | } 177 | attempt.current++ 178 | if (delay) await sleep(delay) 179 | const d = await doFetch(routeOrBody, body) 180 | return d 181 | } 182 | 183 | if (suspense) { 184 | return async (...args) => { 185 | suspender.current = doFetch(...args).then( 186 | (newData) => { 187 | suspenseStatus.current = 'success' 188 | return newData 189 | }, 190 | () => { 191 | suspenseStatus.current = 'error' 192 | } 193 | ) 194 | forceUpdate() 195 | const newData = await suspender.current 196 | return newData 197 | } 198 | } 199 | 200 | return doFetch 201 | }, [isServer, onAbort, requestInit, host, path, interceptors, cachePolicy, perPage, timeout, persist, cacheLife, onTimeout, defaults.data, onNewData, forceUpdate, suspense]) 202 | 203 | const post = useCallback(makeFetch(HTTPMethod.POST), [makeFetch]) 204 | const del = useCallback(makeFetch(HTTPMethod.DELETE), [makeFetch]) 205 | 206 | const request: Req = useMemo(() => Object.defineProperties({ 207 | get: makeFetch(HTTPMethod.GET), 208 | post, 209 | patch: makeFetch(HTTPMethod.PATCH), 210 | put: makeFetch(HTTPMethod.PUT), 211 | options: makeFetch(HTTPMethod.OPTIONS), 212 | head: makeFetch(HTTPMethod.HEAD), 213 | connect: makeFetch(HTTPMethod.CONNECT), 214 | trace: makeFetch(HTTPMethod.TRACE), 215 | del, 216 | delete: del, 217 | abort: () => controller.current && controller.current.abort(), 218 | query: (query: any, variables: any) => post({ query, variables }), 219 | mutate: (mutation: any, variables: any) => post({ mutation, variables }), 220 | cache 221 | }, { 222 | loading: { get: () => loading.current }, 223 | error: { get: () => error.current }, 224 | data: { get: () => data.current }, 225 | }), [makeFetch]) 226 | 227 | const response = useMemo(() => toResponseObject(res, data), []) 228 | 229 | // onMount/onUpdate 230 | useEffect((): any => { 231 | mounted.current = true 232 | if (Array.isArray(dependencies)) { 233 | const methodName = requestInit.method || HTTPMethod.GET 234 | const methodLower = methodName.toLowerCase() as keyof ReqMethods 235 | const req = request[methodLower] as NoArgs 236 | req() 237 | } 238 | return () => mounted.current = false 239 | }, dependencies) 240 | 241 | // Cancel any running request when unmounting to avoid updating state after component has unmounted 242 | // This can happen if a request's promise resolves after component unmounts 243 | useEffect(() => request.abort, []) 244 | 245 | if (suspense && suspender.current) { 246 | if (isServer) throw new Error('Suspense on server side is not yet supported! 🙅‍♂️') 247 | switch (suspenseStatus.current) { 248 | case 'pending': 249 | throw suspender.current 250 | case 'error': 251 | throw error.current 252 | } 253 | } 254 | return Object.assign, UseFetchObjectReturn>( 255 | [request, response, loading.current, error.current], 256 | { request, response, ...request, loading: loading.current, data: data.current, error: error.current } 257 | ) 258 | } 259 | 260 | export { useFetch } 261 | export default useFetch 262 | -------------------------------------------------------------------------------- /src/useFetchArgs.ts: -------------------------------------------------------------------------------- 1 | import { Interceptors, OverwriteGlobalOptions, Options, IncomingOptions, UseFetchArgsReturn, CustomOptions } from './types' 2 | import { isString, isObject, invariant, pullOutRequestInit, isFunction, isPositiveNumber } from './utils' 3 | import { useContext, useMemo } from 'react' 4 | import FetchContext from './FetchContext' 5 | import defaults, { useFetchArgsDefaults } from './defaults' 6 | 7 | 8 | export default function useFetchArgs( 9 | urlOrPathOrOptionsOrOverwriteGlobalOptions?: string | IncomingOptions | OverwriteGlobalOptions, 10 | optionsOrOverwriteGlobalOrDeps?: IncomingOptions | OverwriteGlobalOptions | any[], 11 | deps?: any[] 12 | ): UseFetchArgsReturn { 13 | invariant( 14 | !(isObject(urlOrPathOrOptionsOrOverwriteGlobalOptions) && isObject(optionsOrOverwriteGlobalOrDeps)), 15 | 'You cannot have a 2nd parameter of useFetch as object when your first argument is an object.' 16 | ) 17 | const context = useContext(FetchContext) 18 | 19 | const host = useMemo((): string => { 20 | const maybeHost = urlOrPathOrOptionsOrOverwriteGlobalOptions as string 21 | if (isString(maybeHost) && maybeHost.includes('://')) return maybeHost 22 | if (context.url) return context.url 23 | return defaults.host 24 | }, [context.url, urlOrPathOrOptionsOrOverwriteGlobalOptions]) 25 | 26 | const path = useMemo((): string | undefined => { 27 | const maybePath = urlOrPathOrOptionsOrOverwriteGlobalOptions as string 28 | if (isString(maybePath) && !maybePath.includes('://')) return maybePath 29 | }, [urlOrPathOrOptionsOrOverwriteGlobalOptions]) 30 | 31 | const overwriteGlobalOptions = useMemo((): OverwriteGlobalOptions | undefined => { 32 | if (isFunction(urlOrPathOrOptionsOrOverwriteGlobalOptions)) return urlOrPathOrOptionsOrOverwriteGlobalOptions as OverwriteGlobalOptions 33 | if (isFunction(optionsOrOverwriteGlobalOrDeps)) return optionsOrOverwriteGlobalOrDeps as OverwriteGlobalOptions 34 | }, []) 35 | 36 | const options = useMemo(() => { 37 | let localOptions = { headers: {} } as IncomingOptions 38 | if (isObject(urlOrPathOrOptionsOrOverwriteGlobalOptions)) { 39 | localOptions = urlOrPathOrOptionsOrOverwriteGlobalOptions as IncomingOptions 40 | } else if (isObject(optionsOrOverwriteGlobalOrDeps)) { 41 | localOptions = optionsOrOverwriteGlobalOrDeps as IncomingOptions 42 | } 43 | let globalOptions = context.options 44 | const finalOptions = { 45 | ...defaults, 46 | ...globalOptions, 47 | ...localOptions, 48 | headers: { 49 | ...defaults.headers, 50 | ...globalOptions.headers, 51 | ...localOptions.headers 52 | } as Headers, 53 | interceptors: { 54 | ...defaults.interceptors, 55 | ...globalOptions.interceptors, 56 | ...localOptions.interceptors 57 | } as Interceptors 58 | } as Options 59 | if (overwriteGlobalOptions) return overwriteGlobalOptions(finalOptions) 60 | return finalOptions 61 | }, [urlOrPathOrOptionsOrOverwriteGlobalOptions, overwriteGlobalOptions, context.options]) 62 | 63 | const requestInit = useMemo(() => pullOutRequestInit(options), [options]) 64 | 65 | const dependencies = useMemo((): any[] | undefined => { 66 | if (Array.isArray(optionsOrOverwriteGlobalOrDeps)) return optionsOrOverwriteGlobalOrDeps 67 | if (Array.isArray(deps)) return deps 68 | return defaults.dependencies 69 | }, [optionsOrOverwriteGlobalOrDeps, deps]) 70 | 71 | const { cacheLife, retries, retryDelay, retryOn } = options 72 | invariant(Number.isInteger(cacheLife) && cacheLife >= 0, '`cacheLife` must be a number >= 0') 73 | invariant(Number.isInteger(retries) && retries >= 0, '`retries` must be a number >= 0') 74 | invariant(isFunction(retryDelay) || Number.isInteger(retryDelay as number) && retryDelay >= 0, '`retryDelay` must be a positive number or a function returning a positive number.') 75 | const isValidRetryOn = isFunction(retryOn) || (Array.isArray(retryOn) && retryOn.every(isPositiveNumber)) 76 | invariant(isValidRetryOn, '`retryOn` must be an array of positive numbers or a function returning a boolean.') 77 | const loading = options.loading || Array.isArray(dependencies) 78 | 79 | const interceptors = useMemo((): Interceptors => { 80 | const final: Interceptors = {} 81 | if ('request' in options.interceptors) final.request = options.interceptors.request 82 | if ('response' in options.interceptors) final.response = options.interceptors.response 83 | return final 84 | }, [options]) 85 | 86 | const customOptions = useMemo((): CustomOptions => { 87 | const customOptionKeys = Object.keys(useFetchArgsDefaults.customOptions) as (keyof CustomOptions)[] // Array 88 | const customOptions = customOptionKeys.reduce((opts, key) => { 89 | (opts as any)[key] = options[key] 90 | return opts 91 | }, {} as CustomOptions) 92 | return { ...customOptions, interceptors, loading } 93 | }, [interceptors, loading]) 94 | 95 | return { 96 | host, 97 | path, 98 | customOptions, 99 | requestInit, 100 | dependencies 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/useMutation.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback } from 'react' 2 | import useFetch from './useFetch' 3 | import { FetchContext } from './FetchContext' 4 | import { ReqBase } from './types' 5 | import { invariant, isString, useURLRequiredInvariant } from './utils' 6 | 7 | type ArrayDestructure = [ 8 | TData | undefined, 9 | boolean, 10 | Error | undefined, 11 | (variables?: object) => Promise, 12 | ] 13 | interface ObjectDestructure extends ReqBase { 14 | mutate: (variables?: object) => Promise 15 | } 16 | type UseMutation = ArrayDestructure & ObjectDestructure 17 | 18 | export const useMutation = ( 19 | urlOrMutation: string | TemplateStringsArray, 20 | mutationArg?: string 21 | ): UseMutation => { 22 | const context = useContext(FetchContext) 23 | 24 | useURLRequiredInvariant( 25 | !!context.url && Array.isArray(urlOrMutation), 26 | 'useMutation' 27 | ) 28 | useURLRequiredInvariant( 29 | !!context.url || (isString(urlOrMutation) && !mutationArg), 30 | 'useMutation', 31 | 'OR you need to do useMutation("https://example.com", `your graphql mutation`)' 32 | ) 33 | 34 | // regular no context: useMutation('https://example.com', `graphql MUTATION`) 35 | let url = urlOrMutation 36 | let MUTATION = mutationArg as string 37 | 38 | // tagged template literal with context: useMutation`graphql MUTATION` 39 | if (Array.isArray(urlOrMutation) && context.url) { 40 | invariant( 41 | !mutationArg, 42 | 'You cannot have a 2nd argument when using tagged template literal syntax with useMutation.' 43 | ) 44 | url = context.url 45 | MUTATION = urlOrMutation[0] 46 | 47 | // regular with context: useMutation(`graphql MUTATION`) 48 | } else if (urlOrMutation && !mutationArg && context.url) { 49 | url = context.url 50 | MUTATION = urlOrMutation as string 51 | } 52 | 53 | const { loading, error, cache, ...request } = useFetch(url as string) 54 | 55 | const mutate = useCallback( 56 | (inputs?: object): Promise => request.mutate(MUTATION, inputs), 57 | [MUTATION, request] 58 | ) 59 | 60 | const data = (request.data as TData & { data: any } || { data: undefined }).data 61 | 62 | return Object.assign, ObjectDestructure>( 63 | [data, loading, error, mutate], 64 | { data, loading, error, mutate, cache } 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useCallback } from 'react' 2 | import useFetch from './useFetch' 3 | import { FetchContext } from './FetchContext' 4 | import { ReqBase, Cache } from './types' 5 | import { invariant, isString, useURLRequiredInvariant } from './utils' 6 | 7 | type ArrayDestructure = [ 8 | TData | undefined, 9 | boolean, 10 | Error | undefined, 11 | (variables?: object) => Promise, 12 | ] 13 | interface ObjectDestructure extends ReqBase { 14 | query: (variables?: object) => Promise 15 | cache: Cache 16 | } 17 | type UseQuery = ArrayDestructure & ObjectDestructure 18 | 19 | export const useQuery = ( 20 | urlOrQuery: string | TemplateStringsArray, 21 | queryArg?: string 22 | ): UseQuery => { 23 | const context = useContext(FetchContext) 24 | 25 | useURLRequiredInvariant( 26 | !!context.url && Array.isArray(urlOrQuery), 27 | 'useQuery' 28 | ) 29 | useURLRequiredInvariant( 30 | !!context.url || (isString(urlOrQuery) && !queryArg), 31 | 'useQuery', 32 | 'OR you need to do useQuery("https://example.com", `your graphql query`)' 33 | ) 34 | 35 | // regular no context: useQuery('https://example.com', `graphql QUERY`) 36 | let url = urlOrQuery 37 | let QUERY = queryArg as string 38 | 39 | // tagged template literal with context: useQuery`graphql QUERY` 40 | if (Array.isArray(urlOrQuery) && context.url) { 41 | invariant( 42 | !queryArg, 43 | 'You cannot have a 2nd argument when using tagged template literal syntax with useQuery.' 44 | ) 45 | url = context.url 46 | QUERY = urlOrQuery[0] 47 | 48 | // regular with context: useQuery(`graphql QUERY`) 49 | } else if (urlOrQuery && !queryArg && context.url) { 50 | url = context.url 51 | QUERY = urlOrQuery as string 52 | } 53 | 54 | const { loading, error, cache, ...request } = useFetch(url as string) 55 | 56 | const query = useCallback( 57 | (variables?: object): Promise => request.query(QUERY, variables), 58 | [QUERY, request] 59 | ) 60 | 61 | const data = (request.data as TData & { data: any } || { data: undefined }).data 62 | 63 | return Object.assign, ObjectDestructure>( 64 | [data, loading, error, query], 65 | { data, loading, error, query, cache } 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useEffect, MutableRefObject, useRef, useCallback, DependencyList } from 'react' 2 | import useSSR from 'use-ssr' 3 | import { RequestInitJSON, Options, Res, HTTPMethod, ResponseType } from './types' 4 | import { FunctionKeys, NonFunctionKeys } from 'utility-types' 5 | 6 | /** 7 | * Used for error checking. If the condition is false, throw an error 8 | */ 9 | export function invariant( 10 | condition: boolean, 11 | format: string, 12 | a = '', 13 | b = '', 14 | c = '', 15 | d = '', 16 | e = '', 17 | f = '' 18 | ): void { 19 | if (process.env.NODE_ENV !== 'production') { 20 | if (format === undefined) { 21 | throw new Error('invariant requires an error message argument') 22 | } 23 | } 24 | 25 | if (!condition) { 26 | let error 27 | if (format === undefined) { 28 | error = new Error( 29 | 'Minified exception occurred; use the non-minified dev environment ' + 30 | 'for the full error message and additional helpful warnings.' 31 | ) 32 | } else { 33 | const args = [a, b, c, d, e, f] 34 | let argIndex = 0 35 | error = new Error(format.replace(/%s/g, (): string => args[argIndex++])) 36 | error.name = 'Invariant Violation' 37 | } 38 | 39 | throw error 40 | } 41 | } 42 | 43 | export const useExampleURL = (): string => { 44 | const { isBrowser } = useSSR() 45 | return useMemo( 46 | (): string => 47 | isBrowser ? (window.location.origin as string) : 'https://example.com', 48 | [isBrowser] 49 | ) 50 | } 51 | 52 | export function useURLRequiredInvariant( 53 | condition: boolean, 54 | method: string, 55 | optionalMessage?: string 56 | ): void { 57 | const exampleURL = useExampleURL() 58 | useEffect((): void => { 59 | invariant( 60 | condition, 61 | `${method} requires a URL to be set as the 1st argument,\n 62 | unless you wrap your app like:\n 63 | \n 64 | ${optionalMessage}` 65 | ) 66 | }, [condition, exampleURL, method, optionalMessage]) 67 | } 68 | 69 | export const isString = (x: any): x is string => typeof x === 'string' // eslint-disable-line 70 | 71 | /** 72 | * Determines if the given param is an object. {} 73 | * @param obj 74 | */ 75 | export const isObject = (obj: any): obj is object => Object.prototype.toString.call(obj) === '[object Object]' // eslint-disable-line 76 | 77 | /** 78 | * Determines if the given param is an object that can be used as a request body. 79 | * Returns true for native objects or arrays. 80 | * @param obj 81 | */ 82 | export const isBodyObject = (obj: any): boolean => isObject(obj) || Array.isArray(obj) 83 | 84 | export const isFunction = (v: any): boolean => typeof v === 'function' 85 | 86 | export const isNumber = (v: any): boolean => Object.prototype.toString.call(v) === '[object Number]' 87 | 88 | // const requestFields = Object.getOwnPropertyNames(Object.getPrototypeOf(new Request(''))) 89 | // const responseFields = Object.getOwnPropertyNames(Object.getPrototypeOf(new Response())) 90 | // export const customResponseFields = [...responseFields, 'data'] 91 | 92 | // TODO: come back and fix the "anys" in this http://bit.ly/2Lm3OLi 93 | /** 94 | * Makes an object that will match the standards of a normal fetch's options 95 | * aka: pulls out all useFetch's special options like "onMount" 96 | */ 97 | export const pullOutRequestInit = (options?: Options): RequestInit => { 98 | if (!options) return {} 99 | const requestInitFields = [ 100 | 'body', 101 | 'cache', 102 | 'credentials', 103 | 'headers', 104 | 'integrity', 105 | 'keepalive', 106 | 'method', 107 | 'mode', 108 | 'redirect', 109 | 'referrer', 110 | 'referrerPolicy', 111 | 'signal', 112 | 'window' 113 | ] as (keyof RequestInitJSON)[] 114 | return requestInitFields.reduce( 115 | (acc: RequestInit, key: keyof RequestInit): RequestInit => { 116 | if (key in options) acc[key] = options[key] 117 | return acc 118 | }, 119 | {} 120 | ) 121 | } 122 | 123 | export const isEmpty = (x: any) => x === undefined || x === null 124 | 125 | export enum Device { 126 | Browser = 'browser', 127 | Server = 'server', 128 | Native = 'native', 129 | } 130 | 131 | const { Browser, Server, Native } = Device 132 | 133 | const canUseDOM = !!( 134 | typeof window !== 'undefined' && 135 | window.document && 136 | window.document.createElement 137 | ) 138 | 139 | const canUseNative: boolean = typeof navigator !== 'undefined' && navigator.product === 'ReactNative' 140 | 141 | const device = canUseNative ? Native : canUseDOM ? Browser : Server 142 | 143 | export const isBrowser = device === Browser 144 | export const isServer = device === Server 145 | export const isNative = device === Native 146 | 147 | export const tryGetData = async (res: Response | undefined, defaultData: any, responseType: ResponseType) => { 148 | if (typeof res === 'undefined') throw Error('Response cannot be undefined... 😵') 149 | if (typeof responseType === 'undefined') throw Error('responseType cannot be undefined... 😵') 150 | const types = (Array.isArray(responseType) ? responseType : [responseType]) as ResponseType 151 | if (types[0] == null) throw Error('could not parse data from response 😵') 152 | const data = await tryRetry(res, types) 153 | return !isEmpty(defaultData) && isEmpty(data) ? defaultData : data 154 | } 155 | 156 | const tryRetry = async (res: Response, types: ResponseType, i: number = 0): Promise => { 157 | try { 158 | return await (res.clone() as any)[types[i]]() 159 | } catch (error) { 160 | if (types.length - 1 === i) throw error 161 | return tryRetry(res.clone(), types, ++i) 162 | } 163 | } 164 | 165 | /** 166 | * TODO: missing some fields that are in the mozilla docs: https://developer.mozilla.org/en-US/docs/Web/API/Response#Properties 167 | * 1. trailers (inconsistancy in the docs. Part says `trailers` another says `trailer`) 168 | * 2. useFinalURL 169 | */ 170 | type ResponseFields = (NonFunctionKeys> | 'data') 171 | export const responseFields: ResponseFields[] = ['headers', 'ok', 'redirected', 'trailer', 'status', 'statusText', 'type', 'url', 'body', 'bodyUsed', 'data'] 172 | /** 173 | * TODO: missing some methods that are in the mozilla docs: https://developer.mozilla.org/en-US/docs/Web/API/Response#Methods 174 | * 1. error 175 | * 2. redirect 176 | */ 177 | type ResponseMethods = Exclude>, 'data'> 178 | export const responseMethods: ResponseMethods[] = ['clone', 'arrayBuffer', 'blob', 'formData', 'json', 'text'] 179 | // const responseFields = [...Object.getOwnPropertyNames(Object.getPrototypeOf(new Response())), 'data'].filter(p => p !== 'constructor') 180 | type ResponseKeys = (keyof Res) 181 | export const responseKeys: ResponseKeys[] = [...responseFields, ...responseMethods] 182 | export const toResponseObject = (res?: Response | MutableRefObject, data?: any) => Object.defineProperties( 183 | {}, 184 | responseKeys.reduce((acc: any, field: ResponseKeys) => { 185 | if (responseFields.includes(field as any)) { 186 | acc[field] = { 187 | get: () => { 188 | const response = res instanceof Response ? res : res && res.current 189 | if (!response) return 190 | if (field === 'data') return data.current 191 | const clonedResponse = ('clone' in response ? response.clone() : {}) as Res 192 | return clonedResponse[field as (NonFunctionKeys> | 'data')] 193 | }, 194 | enumerable: true 195 | } 196 | } else if (responseMethods.includes(field as any)) { 197 | acc[field] = { 198 | value: () => { 199 | const response = res instanceof Response ? res : res && res.current 200 | if (!response) return 201 | const clonedResponse = ('clone' in response ? response.clone() : {}) as Res 202 | return clonedResponse[field as Exclude>, 'data'>]() 203 | }, 204 | enumerable: true 205 | } 206 | } 207 | return acc 208 | }, {})) 209 | 210 | export const emptyCustomResponse = toResponseObject() 211 | 212 | // TODO: switch this to .reduce() 213 | const headersAsObject = (headers: Headers): object => { 214 | const obj: any = {} 215 | headers.forEach((value, key) => { 216 | obj[key] = value 217 | }) 218 | return obj 219 | } 220 | 221 | export const serializeResponse = async (response: Response) => { 222 | const body = await response.text() 223 | const { status, statusText } = response 224 | const headers = headersAsObject(response.headers) 225 | return { 226 | body, 227 | status, 228 | statusText, 229 | headers 230 | } 231 | } 232 | 233 | function useDeepCompareMemoize(value: DependencyList) { 234 | const ref = useRef() 235 | if (JSON.stringify(value) !== JSON.stringify(ref.current)) ref.current = value 236 | return ref.current as DependencyList 237 | } 238 | 239 | export const useDeepCallback = (cb: (method: HTTPMethod) => (...args: any) => any, deps: DependencyList) => useCallback(cb, useDeepCompareMemoize(deps)) 240 | 241 | export const sleep = (ms: number) => new Promise((resolve: any) => setTimeout(resolve, ms)) 242 | 243 | export const isPositiveNumber = (n: number) => Number.isInteger(n) && n > 0 244 | 245 | export const makeError = (name: string | number, message: string) => { 246 | const error = new Error(message) 247 | error.name = name + '' 248 | return error 249 | } 250 | 251 | /** 252 | * Determines if we need to add a slash to front 253 | * of a path, and adds it if we do. 254 | * Cases: 255 | * (path = '', url = '' || null | undefined) => '' 256 | * (path = '?foo=bar', url = 'a.com') => '?foo=bar' 257 | * (path = '?foo=bar', url = 'a.com/') => '?foo=bar' 258 | * (path = 'bar', url = 'a.com/?foo=') => 'bar' 259 | * (path = 'foo', url = 'a.com') => '/foo' 260 | * (path = 'foo', url = 'a.com/') => 'foo' 261 | * (path = '/foo', url = 'a.com') => '/foo' 262 | * (path = '/foo', url = 'a.com/') => 'foo' 263 | * (path = '?foo=bar') => '?foo=bar' 264 | * (path = 'foo') => '/foo' 265 | * (path = '/foo') => '/foo' 266 | * (path = '&foo=bar', url = 'a.com?b=k') => '&foo=bar' 267 | * (path = '&foo=bar') => '&foo=bar' 268 | */ 269 | export const addSlash = (input?: string, url?: string) => { 270 | if (!input) return '' 271 | if (!url) { 272 | if (input.startsWith('?') || input.startsWith('&') || input.startsWith('/')) return input 273 | return `/${input}` 274 | } 275 | if (url.endsWith('/') && input.startsWith('/')) return input.substr(1) 276 | if (!url.endsWith('/') && !input.startsWith('/') && !input.startsWith('?') && !input.startsWith('&') && !url.includes('?') ) return `/${input}` 277 | return input 278 | } 279 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "lib": [ 8 | "es5", 9 | "es2015", 10 | "es2016", 11 | "dom", 12 | "esnext" 13 | ], 14 | "types": ["node", "jest"], 15 | "module": "es2015", 16 | "moduleResolution": "node", 17 | "noImplicitAny": true, 18 | "noUnusedLocals": true, 19 | "outDir": "dist/esm", 20 | "sourceMap": true, 21 | "strict": true, 22 | "target": "es5" 23 | }, 24 | "include": [ 25 | "src/*.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.production.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false 5 | } 6 | } 7 | --------------------------------------------------------------------------------