├── .babelrc ├── .circleci └── config.yml ├── .eslintignore ├── .flowconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── README.md ├── basic │ ├── README.md │ ├── basic.gif │ ├── index.html │ └── index.js ├── depends │ ├── README.md │ ├── depends.gif │ ├── index.html │ └── index.js └── pagination │ ├── README.md │ ├── index.html │ ├── index.js │ └── pagination.gif ├── index.d.ts ├── index.js ├── index.js.flow ├── jest └── setupTests.js ├── package.json ├── test ├── __snapshots__ │ └── usePaginatedRequest.test.js.snap ├── flattenInput.test.js ├── useFetch.test.js └── usePaginatedRequest.test.js ├── tsconfig.json ├── usePaginatedRequest.d.ts ├── usePaginatedRequest.js ├── usePaginatedRequest.js.flow ├── usePromise.d.ts ├── usePromise.js ├── usePromise.js.flow ├── utils └── flattenInput.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-flow"], 3 | "plugins": [ 4 | "babel-plugin-transform-flow-strip-types", 5 | ["@babel/plugin-transform-runtime", 6 | { 7 | "regenerator": true 8 | } 9 | ] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | 7 | defaults: &defaults 8 | working_directory: ~/repo 9 | docker: 10 | - image: circleci/node:10.15 11 | 12 | jobs: 13 | install: 14 | <<: *defaults 15 | 16 | steps: 17 | - checkout 18 | 19 | # Download and cache dependencies 20 | - restore_cache: 21 | keys: 22 | - v1-dependencies-{{ checksum "package.json" }} 23 | # fallback to using the latest cache if no exact match is found 24 | - v1-dependencies- 25 | 26 | - run: yarn install 27 | 28 | - save_cache: 29 | paths: 30 | - node_modules 31 | key: v1-dependencies-{{ checksum "package.json" }} 32 | 33 | - persist_to_workspace: 34 | root: ~/repo 35 | paths: . 36 | 37 | flow: 38 | <<: *defaults 39 | 40 | steps: 41 | - attach_workspace: 42 | at: ~/repo 43 | - run: 44 | name: Run Flow 45 | command: npm run flow:check 46 | 47 | typescript: 48 | <<: *defaults 49 | 50 | steps: 51 | - attach_workspace: 52 | at: ~/repo 53 | - run: 54 | name: Run TypeScript 55 | command: npm run typescript 56 | 57 | test: 58 | <<: *defaults 59 | 60 | steps: 61 | - attach_workspace: 62 | at: ~/repo 63 | - run: 64 | name: Run tests 65 | command: npm test 66 | 67 | lint: 68 | <<: *defaults 69 | 70 | steps: 71 | - attach_workspace: 72 | at: ~/repo 73 | - run: 74 | name: Run linting check 75 | command: npm run lint 76 | 77 | size: 78 | <<: *defaults 79 | 80 | steps: 81 | - attach_workspace: 82 | at: ~/repo 83 | - run: 84 | name: Run package size check 85 | command: npm run size 86 | 87 | deploy: 88 | <<: *defaults 89 | steps: 90 | - attach_workspace: 91 | at: ~/repo 92 | - run: 93 | name: Authenticate with registry 94 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 95 | - run: 96 | name: Publish package 97 | command: npm publish 98 | 99 | workflows: 100 | version: 2 101 | test-deploy: 102 | jobs: 103 | - install: 104 | filters: 105 | tags: 106 | only: /^v.*/ 107 | - flow: 108 | requires: 109 | - install 110 | filters: 111 | tags: 112 | only: /^v.*/ 113 | - typescript: 114 | requires: 115 | - install 116 | filters: 117 | tags: 118 | only: /^v.*/ 119 | - test: 120 | requires: 121 | - install 122 | filters: 123 | tags: 124 | only: /^v.*/ 125 | - lint: 126 | requires: 127 | - install 128 | filters: 129 | tags: 130 | only: /^v.*/ 131 | - size: 132 | requires: 133 | - install 134 | filters: 135 | tags: 136 | only: /^v.*/ 137 | - deploy: 138 | requires: 139 | - flow 140 | - typescript 141 | - test 142 | - lint 143 | - size 144 | filters: 145 | tags: 146 | only: /^v.*/ 147 | branches: 148 | ignore: /.*/ 149 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | dist/ 3 | node_modules/ 4 | flow-typed/ 5 | test/ 6 | types/ 7 | examples/ 8 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | ./flow-typed 7 | 8 | [lints] 9 | 10 | [options] 11 | module.file_ext=.js 12 | module.file_ext=.jsx 13 | 14 | [strict] 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: lm-a 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | 4 | .cache/ 5 | dist/ 6 | node_modules/ 7 | .idea/ 8 | .vscode/ 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.log 2 | npm-debug.log* 3 | 4 | # Coverage directory used by tools like istanbul 5 | coverage 6 | .nyc_output 7 | 8 | # Dependency directories 9 | node_modules 10 | 11 | # npm package lock 12 | package-lock.json 13 | yarn.lock 14 | 15 | # project files 16 | src 17 | test 18 | examples 19 | .travis.yml 20 | .babelrc 21 | .gitignore 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ilialesik@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ilya Lesik 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-fetch-hook 2 | 3 | [![CircleCI](https://circleci.com/gh/ilyalesik/react-fetch-hook.svg?style=shield)](https://circleci.com/gh/ilyalesik/react-fetch-hook) 4 | [![npm version](https://img.shields.io/npm/v/react-fetch-hook.svg)](https://www.npmjs.com/package/react-fetch-hook) 5 | [![npm downloads](https://img.shields.io/npm/dt/react-fetch-hook.svg)](https://www.npmjs.com/package/react-fetch-hook) 6 | 7 | React hook for conveniently use Fetch API. 8 | 9 | * **Tiny** (556 B). Calculated by [size-limit](https://github.com/ai/size-limit) 10 | * Both **Flow** and **TypeScript** types included 11 | 12 | ```javascript 13 | import React from "react"; 14 | import useFetch from "react-fetch-hook"; 15 | 16 | const Component = () => { 17 | const { isLoading, data } = useFetch("https://swapi.co/api/people/1"); 18 | 19 | return isLoading ? ( 20 |
Loading...
21 | ) : ( 22 | 23 | ); 24 | }; 25 | 26 | ``` 27 | 28 | *useFetch* accepts the same arguments as *fetch* function. 29 | 30 | ## Installation 31 | 32 | Install it with yarn: 33 | 34 | ``` 35 | yarn add react-fetch-hook 36 | ``` 37 | 38 | Or with npm: 39 | 40 | ``` 41 | npm i react-fetch-hook --save 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### Custom formatter 47 | 48 | Default is `response => response.json()` formatter. You can pass custom formatter: 49 | 50 | ```javascript 51 | const { isLoading, data } = useFetch("https://swapi.co/api/people/1", { 52 | formatter: (response) => response.text() 53 | }); 54 | 55 | ``` 56 | 57 | ### Error handling 58 | 59 | The `useFetch` hook returns an `error` field at any fetch exception. 60 | The `error` field extends [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) 61 | and has `status` and `statusText` fields equal to [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response). 62 | 63 | ```javascript 64 | ... 65 | 66 | const Component = () => { 67 | const { isLoading, data, error } = useFetch("https://swapi.co/api/people/1"); 68 | 69 | if (error) { 70 | return
71 |

Code: ${error.status}

72 |

Message: ${error.statusText}

73 |
74 | } 75 | 76 | ... 77 | }; 78 | 79 | ``` 80 | 81 | ### Multiple requests 82 | Multiple `useFetch` in the same file/component supported: 83 | 84 | ```javascript 85 | const result1 = useFetch("https://swapi.co/api/people/1"); 86 | const result2 = useFetch("https://swapi.co/api/people/2"); 87 | 88 | if (result1.isLoading && result2.isLoading) { 89 | return
Loading...
; 90 | } 91 | 92 | return
93 | 94 | 95 |
96 | ``` 97 | 98 | ### Depends 99 | The request will not be called until all elements of `depends` array be truthy. Example: 100 | 101 | ```javascript 102 | const {authToken} = useContext(authTokenContext); 103 | const [someState, setSomeState] = useState(false); 104 | const { isLoading, data } = useFetch("https://swapi.co/api/people/1", { 105 | depends: [!!authToken, someState] // don't call request, if haven't authToken OR someState: false 106 | }); 107 | 108 | ``` 109 | See [example](examples/depends). 110 | 111 | ### Re-call requests 112 | If any element of `depends` changed, request will be re-call. For example, you can use [react-use-trigger](https://github.com/ilyalesik/react-use-trigger) for re-call the request: 113 | ```javascript 114 | import createTrigger from "react-use-trigger"; 115 | import useTrigger from "react-use-trigger/useTrigger"; 116 | 117 | const requestTrigger = createTrigger(); 118 | 119 | export const Subscriber = () => { 120 | const requestTriggerValue = useTrigger(requestTrigger); 121 | 122 | const { isLoading, data } = useFetch("https://swapi.co/api/people/1", { 123 | depends: [requestTriggerValue] 124 | }); 125 | 126 | return
; 127 | } 128 | 129 | export const Sender = () => { 130 | return 133 | } 134 | ``` 135 | 136 | ### usePromise 137 | For custom promised function. 138 | 139 | ```javascript 140 | import React from "react"; 141 | import usePromise from "react-fetch-hook/usePromise"; 142 | import callPromise from "..." 143 | 144 | const Component = () => { 145 | const { isLoading, data } = usePromise(() => callPromise(...params), [...params]); 146 | 147 | return isLoading ? ( 148 |
Loading...
149 | ) : ( 150 | 151 | ); 152 | }; 153 | ``` 154 | 155 | ## [Examples](examples) 156 | 157 | * [Basic](examples/basic) - Just fetch data with `useFetch`. 158 | * [Depends](examples/depends) - Usage `depends` option for refresh query. 159 | * [Pagination](examples/pagination) - Usage `usePaginationRequest` for infinite scroll implementation. 160 | 161 | ## API 162 | 163 | ### `useFetch` 164 | Create a hook wrapper for `fetch` call. 165 | ```javascript 166 | useFetch( 167 | path: RequestInfo, 168 | options?: { 169 | ...RequestOptions, 170 | formatter?: Response => Promise 171 | depends?: Array 172 | }, 173 | specialOptions?: { 174 | formatter?: Response => Promise 175 | depends?: Array 176 | } 177 | ): TUseFetchResult 178 | ``` 179 | where `TUseFetchResult` is: 180 | ```javascript 181 | { 182 | data: any, 183 | isLoading: boolean, 184 | error: any 185 | } 186 | ``` 187 | 188 | `RequestInfo`, `RequestOptions` is [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) args. 189 | 190 | 191 | ### `usePromise` 192 | ```javascript 193 | usePromise>( 194 | callFunction: ?(...args: I) => Promise, 195 | ...inputs: I 196 | ): TUsePromiseResult 197 | ``` 198 | where `TUsePromiseResult` is 199 | ```javascript 200 | type TUsePromiseResult = { 201 | data: ?T, 202 | isLoading: boolean, 203 | error: mixed 204 | } 205 | ``` 206 | 207 | ### Experimental: `usePaginatedRequest` 208 | ⚠️ Warning: this method is experimental, API can be changed. 209 | 210 | Create a paginated request. 211 | ```javascript 212 | usePaginatedRequest = ( 213 | request: (params: { limit: number, offset: number }) => Promise>, 214 | limit: number, 215 | ...depends: Array 216 | ): { 217 | data: Array, 218 | loadMore?: () => mixed, 219 | hasMore: boolean 220 | }; 221 | ``` 222 | 223 | ## Who Uses react-fetch-hook 224 | 225 | ### Open Source projects 226 | 227 | * [react-figma](https://github.com/react-figma/react-figma) 228 | * [awesome-web-animation](https://github.com/sergey-pimenov/awesome-web-animation) 229 | * [redux-helpers](https://github.com/lecstor/redux-helpers) 230 | * [flowmap.blue](https://github.com/FlowmapBlue/flowmap.blue) 231 | 232 | ### Companies 233 | 234 | * [Redis Agency](https://redis.agency/) 235 | * [Lessmess Agency](https://lessmess.agency) 236 | * [BigDatr](https://bigdatr.com/) 237 | * [Fabrique numérique des Ministères Sociaux](https://incubateur.social.gouv.fr/) 238 | 239 | [See more](https://github.com/ilyalesik/react-fetch-hook/network/dependents) 240 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | * [Basic](basic) - Just fetch data with `useFetch`. 4 | * [Depends](depends) - Usage `depends` option for refresh query. 5 | * [Pagination](pagination) - Usage `usePaginationRequest` for infinite scroll implementation. 6 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic 2 | 3 | ![Basic example](basic.gif) 4 | 5 | Just fetch data with `useFetch`. 6 | 7 | Try at CodeSandbox: 8 | 9 | [![Edit basic](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/react-fetch-hook-basic-g1in5) 10 | -------------------------------------------------------------------------------- /examples/basic/basic.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilyalesik/react-fetch-hook/52999750d5120f60a90746cba7982c2550150ba9/examples/basic/basic.gif -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import useFetch from "../../index"; 4 | 5 | 6 | const App = () => { 7 | const {isLoading, data, error} = useFetch(`https://swapi.co/api/people/1`); 8 | 9 | console.log(isLoading, data, error && error.status); 10 | 11 | return
12 |

isLoading: {isLoading && "true" || "false"}

13 |

Name: {data && data.name}

14 |
15 | }; 16 | 17 | 18 | if (root) { 19 | ReactDOM.render( 20 | , 21 | document.getElementById("root") 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/depends/README.md: -------------------------------------------------------------------------------- 1 | # Depends 2 | 3 | ![Depends example](depends.gif) 4 | 5 | Usage `depends` option for refresh query. 6 | 7 | Try at CodeSandbox: 8 | 9 | [![Edit basic](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/react-fetch-hook-depends-zpm51) 10 | -------------------------------------------------------------------------------- /examples/depends/depends.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilyalesik/react-fetch-hook/52999750d5120f60a90746cba7982c2550150ba9/examples/depends/depends.gif -------------------------------------------------------------------------------- /examples/depends/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/depends/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import useFetch from "../../index"; 4 | 5 | 6 | const App = () => { 7 | const [counter, setCounter] = React.useState(false); 8 | const {isLoading, data} = useFetch(`http://worldtimeapi.org/api/timezone/Europe/London`,{ 9 | depends: [counter] 10 | }); 11 | 12 | React.useEffect(() => { 13 | const timeoutId = setInterval(() => { 14 | console.log("setTimeoutEnded: true"); 15 | setCounter((counter) => counter + 1); 16 | }, 500); 17 | return () => { 18 | clearTimeout(timeoutId); 19 | } 20 | }, []); 21 | 22 | console.log(isLoading, data); 23 | 24 | return
25 |

counter: {counter}

26 |

isLoading: {isLoading && "true" || "false"}

27 |

UTC date time: {data && data.utc_datetime}

28 |
29 | }; 30 | 31 | 32 | if (root) { 33 | ReactDOM.render( 34 | , 35 | document.getElementById("root") 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /examples/pagination/README.md: -------------------------------------------------------------------------------- 1 | # Pagination 2 | 3 | ![Pagination example](pagination.gif) 4 | 5 | Usage `usePaginationRequest` for infinite scroll implementation. 6 | 7 | Try at CodeSandbox: 8 | 9 | [![Edit basic](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/react-fetch-hook-pagination-nq561) 10 | -------------------------------------------------------------------------------- /examples/pagination/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/pagination/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import useFetch from "../../index"; 4 | import InfiniteScroll from "react-infinite-scroller"; 5 | import usePaginatedRequest from "../../usePaginatedRequest"; 6 | 7 | const searchRepositories = ({searchString}) => async ({offset, limit}) => { 8 | const response = await fetch(`https://api.github.com/search/repositories?q=${searchString}&sort=stars&order=desc&page=${parseInt(offset / limit)}&per_page=${limit}`); 9 | if (!response.ok) { 10 | throw Error(response.statusText); 11 | } 12 | const result = await response.json(); 13 | return result.items; 14 | }; 15 | 16 | const App = () => { 17 | const [searchString, setSearchString] = React.useState(""); 18 | 19 | const result = usePaginatedRequest( 20 | searchRepositories({searchString}), 21 | 100, 22 | searchString); 23 | 24 | console.log(result.data); 25 | 26 | return
27 | setSearchString(e.target.value)} /> 28 |
    29 | }> 34 | {result.data && result.data.map((item) => ( 35 |
  • {item.name} ({item.stargazers_count})
  • 36 | ))} 37 |
    38 |
39 |
40 | }; 41 | 42 | 43 | if (root) { 44 | ReactDOM.render( 45 | , 46 | document.getElementById("root") 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /examples/pagination/pagination.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilyalesik/react-fetch-hook/52999750d5120f60a90746cba7982c2550150ba9/examples/pagination/pagination.gif -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare namespace useFetch { 3 | export interface UseFetchError extends Error { 4 | status: number, 5 | statusText: string 6 | } 7 | 8 | export interface FetchResult { 9 | data?: T, 10 | isLoading: boolean, 11 | error?: UseFetchError 12 | } 13 | 14 | export interface HookOptions extends RequestInit { 15 | depends?: Array 16 | } 17 | 18 | export interface HookOptionsWithFormatter extends HookOptions { 19 | formatter(response: Response): Promise 20 | } 21 | } 22 | 23 | 24 | declare function useFetch(path: RequestInfo, 25 | options?: useFetch.HookOptions | useFetch.HookOptionsWithFormatter, 26 | specialOptions?: useFetch.HookOptions): useFetch.FetchResult; 27 | 28 | export = useFetch; 29 | 30 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var usePromise = require('./usePromise') 2 | 3 | function UseFetchError (status, statusText, message, fileName, lineNumber) { 4 | var instance = new Error(message, fileName, lineNumber) 5 | instance.name = 'UseFetchError' 6 | instance.status = status 7 | instance.statusText = statusText 8 | Object.setPrototypeOf(instance, Object.getPrototypeOf(this)) 9 | if (Error.captureStackTrace) { 10 | Error.captureStackTrace(instance, UseFetchError) 11 | } 12 | return instance 13 | } 14 | 15 | UseFetchError.prototype = Object.create(Error.prototype, { 16 | constructor: { 17 | value: Error, 18 | enumerable: false, 19 | writable: true, 20 | configurable: true 21 | } 22 | }) 23 | 24 | Object.setPrototypeOf(UseFetchError, Error) 25 | 26 | function useFetch ( 27 | path, 28 | options, 29 | specialOptions 30 | ) { 31 | var blocked = ((specialOptions && specialOptions.depends) || 32 | (options && options.depends) || []) 33 | .reduce(function (acc, dep) { return acc || !dep }, false) 34 | return usePromise(!blocked && function (p, o, s) { 35 | return fetch(p, o) 36 | .then((s && s.formatter) || (o && o.formatter) || function (response) { 37 | if (!response.ok) { 38 | throw new UseFetchError( 39 | response.status, 40 | response.statusText, 41 | 'Fetch error' 42 | ) 43 | } 44 | return response.json() 45 | }) 46 | }, 47 | path, options || {}, specialOptions || {}) 48 | } 49 | 50 | module.exports = useFetch 51 | -------------------------------------------------------------------------------- /index.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | type TUseFetchResult = { 4 | data: ?T, 5 | isLoading: boolean, 6 | error?: Error & { 7 | status: number, 8 | statusText: string 9 | } 10 | }; 11 | declare function useFetch(path: RequestInfo, options?: { ...RequestOptions, 12 | formatter?: (Response) => Promise, 13 | depends?: Array, 14 | }, specialOptions?: { 15 | formatter?: (Response) => Promise, 16 | depends?: Array 17 | }): TUseFetchResult; 18 | 19 | declare export default typeof useFetch; 20 | -------------------------------------------------------------------------------- /jest/setupTests.js: -------------------------------------------------------------------------------- 1 | require('jest-dom/extend-expect') 2 | global.fetch = require('jest-fetch-mock') 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fetch-hook", 3 | "version": "1.9.5", 4 | "main": "index.js", 5 | "types": "index.d.ts", 6 | "description": "React fetch hook", 7 | "scripts": { 8 | "flow": "flow", 9 | "flow:check": "flow check", 10 | "typescript": "tsc -p tsconfig.json --noEmit", 11 | "test": "jest --silent", 12 | "size": "size-limit", 13 | "lint": "eslint '**/*.{js,jsx}' --quiet", 14 | "run:example:basic": "parcel ./examples/basic/index.html", 15 | "run:example:depends": "parcel ./examples/depends/index.html", 16 | "run:example:pagination": "parcel ./examples/pagination/index.html --no-cache" 17 | }, 18 | "eslintConfig": { 19 | "extends": [ 20 | "lessmess/lib", 21 | "lessmess/react-hooks" 22 | ], 23 | "rules": { 24 | "func-style": "off" 25 | } 26 | }, 27 | "jest": { 28 | "setupTestFrameworkScriptFile": "./jest/setupTests.js" 29 | }, 30 | "size-limit": [ 31 | { 32 | "ignore": [ 33 | "react" 34 | ], 35 | "limit": "556 B", 36 | "path": "index.js", 37 | "running": false 38 | } 39 | ], 40 | "files": [ 41 | "utils", 42 | "index.js", 43 | "index.js.flow", 44 | "index.d.ts", 45 | "usePromise.js", 46 | "usePromise.js.flow", 47 | "usePromise.d.ts", 48 | "usePaginatedRequest.js", 49 | "usePaginatedRequest.js.flow", 50 | "usePaginatedRequest.d.ts" 51 | ], 52 | "repository": { 53 | "type": "git", 54 | "url": "git+ssh://git@github.com/ilyalesik/react-fetch-hook.git" 55 | }, 56 | "keywords": [ 57 | "react", 58 | "hooks", 59 | "fetch" 60 | ], 61 | "author": "Ilya Lesik", 62 | "license": "MIT", 63 | "bugs": { 64 | "url": "https://github.com/ilyalesik/react-fetch-hook/issues" 65 | }, 66 | "homepage": "https://github.com/ilyalesik/react-fetch-hook#readme", 67 | "peerDependencies": { 68 | "react": ">=16.8.0 <19.0.0" 69 | }, 70 | "devDependencies": { 71 | "@babel/cli": "^7.2.0", 72 | "@babel/core": "^7.2.2", 73 | "@babel/plugin-transform-runtime": "^7.4.4", 74 | "@babel/preset-env": "^7.2.0", 75 | "@babel/preset-flow": "^7.0.0", 76 | "@babel/preset-react": "^7.0.0", 77 | "babel-core": "^7.0.0-bridge.0", 78 | "babel-plugin-react-flow-props-to-prop-types": "^0.15.0", 79 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 80 | "cross-env": "^5.2.0", 81 | "eslint": "^5.16.0", 82 | "eslint-config-lessmess": "0.0.1", 83 | "eslint-config-standard": "^12.0.0", 84 | "eslint-plugin-es5": "^1.3.1", 85 | "eslint-plugin-import": "^2.17.2", 86 | "eslint-plugin-import-helpers": "^0.1.4", 87 | "eslint-plugin-jest": "^22.5.1", 88 | "eslint-plugin-node": "^9.0.1", 89 | "eslint-plugin-prefer-let": "^1.0.1", 90 | "eslint-plugin-promise": "^4.1.1", 91 | "eslint-plugin-react-hooks": "^1.6.0", 92 | "eslint-plugin-security": "^1.4.0", 93 | "eslint-plugin-standard": "^4.0.0", 94 | "flow-bin": "^0.89.0", 95 | "jest": "^23.6.0", 96 | "jest-dom": "^3.0.0", 97 | "jest-fetch-mock": "^2.1.0", 98 | "parcel-bundler": "^1.12.3", 99 | "react": "^16.8.0", 100 | "react-dom": "^16.8.0", 101 | "react-infinite-scroller": "^1.2.4", 102 | "react-test-renderer": "^16.8.0", 103 | "react-testing-library": "^6.0.0", 104 | "rimraf": "^2.6.2", 105 | "size-limit": "^1.3.2", 106 | "typescript": "^3.4.5" 107 | }, 108 | "resolutions": { 109 | "babel-core": "^7.0.0-bridge.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /test/__snapshots__/usePaginatedRequest.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`usePaginatedRequest call loadMore (expect hasMore - false) 1`] = ` 4 |
5 |
6 |
7 | 1 8 |
9 |
10 | 2 11 |
12 |
13 | 3 14 |
15 |
19 |
20 | `; 21 | 22 | exports[`usePaginatedRequest call loadMore 1`] = ` 23 |
24 |
25 |
26 | 1 27 |
28 |
29 | 2 30 |
31 |
32 | 3 33 |
34 |
35 | 4 36 |
37 |
38 | hasMore 39 |
40 |
44 |
45 | `; 46 | 47 | exports[`usePaginatedRequest first load (expect hasMore - false) 1`] = ` 48 |
49 |
50 |
51 | 1 52 |
53 |
57 |
58 | `; 59 | 60 | exports[`usePaginatedRequest first load 1`] = ` 61 |
62 |
63 |
64 | 1 65 |
66 |
67 | 2 68 |
69 |
70 | hasMore 71 |
72 |
76 |
77 | `; 78 | -------------------------------------------------------------------------------- /test/flattenInput.test.js: -------------------------------------------------------------------------------- 1 | import flattenInput from "../utils/flattenInput"; 2 | 3 | describe("flattenInput", () => { 4 | it("simple", () => { 5 | expect(flattenInput(1, 2)).toMatchObject([1, 2]); 6 | }); 7 | 8 | it("with array", () => { 9 | expect(flattenInput(1, [2, 3])).toMatchObject([1, 2, 3]); 10 | }); 11 | 12 | it("with array recursive", () => { 13 | expect(flattenInput(1, [2, [3, 4]])).toMatchObject([1, 2, 3, 4]); 14 | }); 15 | 16 | it("with object", () => { 17 | expect(flattenInput(1, { x: 1, y: 2 })).toMatchObject([1, "x", 1, "y", 2]); 18 | }); 19 | 20 | it("with object recursive", () => { 21 | expect(flattenInput(1, { x: 1, y: 2, z: { x1: 3 } })).toMatchObject([1, "x", 1, "y", 2, "z", "x1", 3]); 22 | }); 23 | 24 | it("with URL instance", () => { 25 | const url = new URL("https://google.com"); 26 | url.search = new URLSearchParams({ a: "aaa", b: "bbb" }).toString(); 27 | const result = flattenInput(url, { x: 1 }); 28 | expect(result).toMatchObject([ 29 | "https://google.com/?a=aaa&b=bbb", 30 | "x", 31 | 1 32 | ]); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/useFetch.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, wait } from "react-testing-library"; 3 | import useFetch from "../index"; 4 | 5 | describe("useFetch", () => { 6 | beforeEach(() => { 7 | fetch.resetMocks(); 8 | }); 9 | 10 | it("call with only url", async () => { 11 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 12 | 13 | const Component = () => { 14 | const result = useFetch("https://google.com"); 15 | return
{result.data && result.data.data}
; 16 | }; 17 | 18 | const { container, rerender } = render(); 19 | 20 | await wait(() => { 21 | rerender(); 22 | 23 | expect(fetch.mock.calls.length).toEqual(1); 24 | expect(container).toHaveTextContent("12345"); 25 | expect(fetch.mock.calls[0][0]).toEqual("https://google.com"); 26 | }); 27 | }); 28 | 29 | it("isLoading by default", async () => { 30 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 31 | 32 | const Component = () => { 33 | const result = useFetch("https://google.com"); 34 | return
{result.isLoading && "test"}
; 35 | }; 36 | 37 | const { container, rerender } = render(); 38 | expect(container).toHaveTextContent("test"); 39 | }); 40 | 41 | it("call with url and options", async () => { 42 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 43 | const options = { 44 | headers: { 45 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 46 | "Content-Type": "application/json; charset=utf-8" 47 | } 48 | }; 49 | 50 | const Component = () => { 51 | const result = useFetch("https://google.com", { ...options }); 52 | return
{result.isLoading}
; 53 | }; 54 | 55 | const { rerender } = render(); 56 | 57 | await wait(() => { 58 | rerender(); 59 | 60 | expect(fetch.mock.calls.length).toEqual(1); 61 | expect(fetch.mock.calls[0][0]).toEqual("https://google.com"); 62 | expect(fetch.mock.calls[0][1]).toMatchObject({ ...options }); 63 | }); 64 | }); 65 | 66 | it("call with url, options with formatter", async () => { 67 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 68 | const options = { 69 | headers: { 70 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 71 | "Content-Type": "application/json; charset=utf-8" 72 | } 73 | }; 74 | const formatterMock = jest.fn(); 75 | formatterMock.mockReturnValueOnce("xxx"); 76 | 77 | const Component = () => { 78 | const result = useFetch("https://google.com", { ...options, formatter: formatterMock }); 79 | return
{result.data}
; 80 | }; 81 | 82 | const { container, rerender } = render(); 83 | 84 | await wait(() => { 85 | rerender(); 86 | 87 | expect(fetch.mock.calls.length).toEqual(1); 88 | expect(formatterMock.mock.calls.length).toEqual(1); 89 | expect(container).toHaveTextContent("xxx"); 90 | expect(fetch.mock.calls[0][0]).toEqual("https://google.com"); 91 | expect(fetch.mock.calls[0][1]).toMatchObject({ ...options }); 92 | }); 93 | }); 94 | 95 | it("call with url, options, special options with formatter", async () => { 96 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 97 | const options = { 98 | headers: { 99 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 100 | "Content-Type": "application/json; charset=utf-8" 101 | } 102 | }; 103 | const formatterMock = jest.fn(); 104 | formatterMock.mockReturnValueOnce("xxx"); 105 | 106 | const Component = () => { 107 | const result = useFetch("https://google.com", { ...options }, { formatter: formatterMock}); 108 | return
{result.data}
; 109 | }; 110 | 111 | const { container, rerender } = render(); 112 | 113 | await wait(() => { 114 | rerender(); 115 | 116 | expect(fetch.mock.calls.length).toEqual(1); 117 | expect(formatterMock.mock.calls.length).toEqual(1); 118 | expect(container).toHaveTextContent("xxx"); 119 | expect(fetch.mock.calls[0][0]).toEqual("https://google.com"); 120 | expect(fetch.mock.calls[0][1]).toMatchObject({ ...options }); 121 | }); 122 | }); 123 | 124 | it("call with url, options with preventCallFetch: false and depends", async () => { 125 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 126 | const options = { 127 | headers: { 128 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 129 | "Content-Type": "application/json; charset=utf-8" 130 | } 131 | }; 132 | 133 | const Component = () => { 134 | const result = useFetch("https://google.com", { ...options, depends: ["xxx"], preventCallFetch: false }); 135 | return
{result.data && result.data.data}
; 136 | }; 137 | 138 | const { container, rerender } = render(); 139 | 140 | await wait(() => { 141 | rerender(); 142 | 143 | expect(fetch.mock.calls.length).toEqual(1); 144 | }); 145 | }); 146 | 147 | it("call with url, options with depends", async () => { 148 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 149 | const options = { 150 | headers: { 151 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 152 | "Content-Type": "application/json; charset=utf-8" 153 | } 154 | }; 155 | 156 | const Component = () => { 157 | const result = useFetch("https://google.com", { ...options, depends: [true, false] }); 158 | return
{result.data && result.data.data}
; 159 | }; 160 | 161 | const { container, rerender } = render(); 162 | 163 | await wait(() => { 164 | rerender(); 165 | 166 | expect(fetch.mock.calls.length).toEqual(0); 167 | }); 168 | }); 169 | 170 | it("call with url, options with depends never set isLoading", async () => { 171 | const isLoadingWatcher = jest.fn(); 172 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 173 | const options = { 174 | headers: { 175 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 176 | "Content-Type": "application/json; charset=utf-8" 177 | } 178 | }; 179 | 180 | const Component = () => { 181 | const result = useFetch("https://google.com", { ...options, depends: [false] }); 182 | const isLoading = result.isLoading; 183 | React.useEffect(() => { 184 | if (isLoading) { 185 | isLoadingWatcher(); 186 | } 187 | }, [isLoading]) 188 | return
{result.data && result.data.data}
; 189 | }; 190 | 191 | const { container, rerender } = render(); 192 | 193 | await wait(() => { 194 | rerender(); 195 | 196 | expect(isLoadingWatcher.mock.calls.length).toEqual(0); 197 | }); 198 | }); 199 | 200 | it("call with url, options with depends at next arg", async () => { 201 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 202 | const options = { 203 | headers: { 204 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 205 | "Content-Type": "application/json; charset=utf-8" 206 | } 207 | }; 208 | 209 | const fetchParams = ["https://google.com", options]; 210 | 211 | const Component = () => { 212 | const result = useFetch(...fetchParams, { depends: [true, false] }); 213 | return
{result.data && result.data.data}
; 214 | }; 215 | 216 | const { container, rerender } = render(); 217 | 218 | await wait(() => { 219 | rerender(); 220 | 221 | expect(fetch.mock.calls.length).toEqual(0); 222 | }); 223 | }); 224 | 225 | it("call with url, options with depends: [true] at next arg", async () => { 226 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 227 | const options = { 228 | headers: { 229 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 230 | "Content-Type": "application/json; charset=utf-8" 231 | } 232 | }; 233 | 234 | const fetchParams = ["https://google.com", options]; 235 | 236 | const Component = () => { 237 | const result = useFetch(...fetchParams, { depends: [true] }); 238 | return
{result.data && result.data.data}
; 239 | }; 240 | 241 | const { container, rerender } = render(); 242 | 243 | await wait(() => { 244 | rerender(); 245 | 246 | expect(fetch.mock.calls.length).toEqual(1); 247 | }); 248 | }); 249 | 250 | it("call with url, options with depends with empty string", async () => { 251 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 252 | const options = { 253 | headers: { 254 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 255 | "Content-Type": "application/json; charset=utf-8" 256 | } 257 | }; 258 | 259 | const Component = () => { 260 | const result = useFetch("https://google.com", { ...options, depends: [""] }); 261 | return
{result.data && result.data.data}
; 262 | }; 263 | 264 | const { container, rerender } = render(); 265 | 266 | await wait(() => { 267 | rerender(); 268 | 269 | expect(fetch.mock.calls.length).toEqual(0); 270 | }); 271 | }); 272 | 273 | it("call with url, options with empty depends", async () => { 274 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 275 | const options = { 276 | headers: { 277 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 278 | "Content-Type": "application/json; charset=utf-8" 279 | } 280 | }; 281 | 282 | const Component = () => { 283 | const result = useFetch("https://google.com", { ...options, depends: [] }); 284 | return
{result.data && result.data.data}
; 285 | }; 286 | 287 | const { container, rerender } = render(); 288 | 289 | await wait(() => { 290 | rerender(); 291 | 292 | expect(fetch.mock.calls.length).toEqual(1); 293 | }); 294 | }); 295 | 296 | it("call with url, options with all true depends", async () => { 297 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 298 | const options = { 299 | headers: { 300 | Accept: "application/json, application/xml, text/plain, text/html, *.*", 301 | "Content-Type": "application/json; charset=utf-8" 302 | } 303 | }; 304 | 305 | const Component = () => { 306 | const result = useFetch("https://google.com", { ...options, depends: [true, true] }); 307 | return
{result.data && result.data.data}
; 308 | }; 309 | 310 | const { container, rerender } = render(); 311 | 312 | await wait(() => { 313 | rerender(); 314 | 315 | expect(fetch.mock.calls.length).toEqual(1); 316 | }); 317 | }); 318 | 319 | it("error on throw error", async () => { 320 | fetch.mockReject(new Error("fake error message")); 321 | 322 | const Component = () => { 323 | const result = useFetch("https://google.com"); 324 | return (result.error && result.error.message) || "text"; 325 | }; 326 | 327 | const { container, rerender } = render(); 328 | 329 | await wait(() => { 330 | rerender(); 331 | 332 | expect(fetch.mock.calls.length).toEqual(1); 333 | expect(container).toHaveTextContent("fake error message"); 334 | expect(fetch.mock.calls[0][0]).toEqual("https://google.com"); 335 | }); 336 | }); 337 | 338 | it("call with URL", async () => { 339 | fetch.mockResponse(JSON.stringify({ data: "12345" })); 340 | 341 | const url = new URL("https://google.com"); 342 | 343 | url.search = new URLSearchParams({a: 1, b: 2}).toString(); 344 | 345 | const Component = () => { 346 | const result = useFetch(url, { 347 | depends: ["1", 1] 348 | }); 349 | return
{result.data && result.data.data}
; 350 | }; 351 | 352 | const { container, rerender } = render(); 353 | 354 | await wait(() => { 355 | rerender(); 356 | }); 357 | 358 | await wait(() => { 359 | rerender(); 360 | 361 | expect(fetch.mock.calls.length).toEqual(1); 362 | expect(container).toHaveTextContent("12345"); 363 | expect(fetch.mock.calls[0][0]).toMatchObject(url); 364 | }); 365 | }); 366 | }); 367 | -------------------------------------------------------------------------------- /test/usePaginatedRequest.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, wait, fireEvent, cleanup } from "react-testing-library"; 3 | import usePaginatedRequest from "../usePaginatedRequest"; 4 | 5 | const ChildComponent = props => { 6 | return ( 7 |
8 | {props.data.map(item => ( 9 |
{item}
10 | ))} 11 | {props.hasMore &&
hasMore
} 12 |
14 | ); 15 | }; 16 | 17 | describe("usePaginatedRequest", () => { 18 | afterEach(cleanup); 19 | 20 | it("first load", async () => { 21 | const request = jest.fn(() => Promise.resolve([1, 2])); 22 | 23 | const Component = () => { 24 | const { data, hasMore, loadMore } = usePaginatedRequest(request, 2); 25 | 26 | return ; 27 | }; 28 | 29 | const { container, rerender } = render(); 30 | 31 | await wait(() => { 32 | expect(container).toMatchSnapshot(); 33 | }); 34 | 35 | await wait(() => { 36 | rerender(); 37 | expect(request.mock.calls.length).toBe(1); 38 | }); 39 | }); 40 | 41 | it("first load (expect hasMore - false)", async () => { 42 | const request = jest.fn(() => Promise.resolve([1])); 43 | 44 | const Component = () => { 45 | const { data, hasMore, loadMore } = usePaginatedRequest(request, 2); 46 | 47 | return ; 48 | }; 49 | 50 | const { container, rerender } = render(); 51 | 52 | await wait(() => { 53 | expect(container).toMatchSnapshot(); 54 | }); 55 | 56 | await wait(() => { 57 | rerender(); 58 | expect(request.mock.calls.length).toBe(1); 59 | }); 60 | }); 61 | 62 | it("call loadMore", async () => { 63 | const request = jest.fn(); 64 | request.mockReturnValueOnce(Promise.resolve([1, 2])).mockReturnValueOnce(Promise.resolve([3, 4])); 65 | 66 | const Component = () => { 67 | const { data, hasMore, loadMore } = usePaginatedRequest(request, 2); 68 | 69 | return ; 70 | }; 71 | 72 | const { container, rerender, getByTestId } = render(); 73 | 74 | await wait(() => { 75 | fireEvent.click(getByTestId("trigger")); 76 | }); 77 | 78 | await wait(() => { 79 | rerender(); 80 | }); 81 | 82 | await wait(() => { 83 | expect(container).toMatchSnapshot(); 84 | expect(request.mock.calls.length).toBe(2); 85 | }); 86 | }); 87 | 88 | it("call loadMore (expect hasMore - false)", async () => { 89 | const request = jest.fn(); 90 | request.mockReturnValueOnce(Promise.resolve([1, 2])).mockReturnValueOnce(Promise.resolve([3])); 91 | 92 | const Component = () => { 93 | const { data, hasMore, loadMore } = usePaginatedRequest(request, 2); 94 | 95 | return ; 96 | }; 97 | 98 | const { container, rerender, getByTestId } = render(); 99 | 100 | await wait(() => { 101 | fireEvent.click(getByTestId("trigger")); 102 | }); 103 | 104 | await wait(() => { 105 | rerender(); 106 | }); 107 | 108 | await wait(() => { 109 | expect(container).toMatchSnapshot(); 110 | expect(request.mock.calls.length).toBe(2); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "jsx": "react", 5 | "lib": [ 6 | "dom", 7 | "es2018" 8 | ], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noImplicitAny": false, 12 | "noImplicitReturns": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "strict": true, 16 | "strictFunctionTypes": false, 17 | "stripInternal": true 18 | }, 19 | "include": [ 20 | "**/*.d.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /usePaginatedRequest.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace usePaginatedRequest { 2 | export type requestFunction = (params: { 3 | limit: number, 4 | offset: number, 5 | }) => Promise> 6 | } 7 | 8 | declare function usePaginatedRequest(request: usePaginatedRequest.requestFunction, limit: number, ...depends: Array): { 9 | data: Array, 10 | loadMore?: () => any, 11 | hasMore: boolean, 12 | }; 13 | 14 | export = usePaginatedRequest; 15 | -------------------------------------------------------------------------------- /usePaginatedRequest.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | function usePaginatedRequest ( 4 | request, 5 | limit 6 | ) { 7 | var depends = Array.prototype.slice.call(arguments, [2]) 8 | var dataState = React.useState([]) 9 | var currentUpdate = React.useRef() 10 | var offsetState = React.useState(0) 11 | var hasMoreState = React.useState(true) 12 | 13 | var loadMoreRef = React.useRef(function () {}) 14 | 15 | React.useEffect(function () { 16 | dataState[1]([]) 17 | offsetState[1](0) 18 | hasMoreState[1](true) 19 | }, depends) 20 | 21 | React.useEffect( 22 | function () { 23 | loadMoreRef.current = async function () { 24 | if (currentUpdate.current) { 25 | await currentUpdate.current 26 | } 27 | if (hasMoreState[0]) { 28 | offsetState[1](offsetState[0] + limit) 29 | } 30 | } 31 | 32 | var update = async function () { 33 | var result = await request({ limit: limit, offset: offsetState[0] }) 34 | hasMoreState[1](result.length === limit) 35 | dataState[1](function (prev) { return [].concat(prev, result) }) 36 | } 37 | currentUpdate.current = update() 38 | }, 39 | [offsetState[0]].concat(depends) 40 | ) 41 | 42 | return { 43 | data: dataState[0], 44 | loadMore: loadMoreRef.current || function () {}, 45 | hasMore: hasMoreState[0] 46 | } 47 | } 48 | 49 | module.exports = usePaginatedRequest 50 | -------------------------------------------------------------------------------- /usePaginatedRequest.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare function usePaginatedRequest(request: (params: { 3 | limit: number, 4 | offset: number, 5 | }) => Promise>, limit: number, ...depends: Array): { 6 | data: Array, 7 | loadMore?: () => mixed, 8 | hasMore: boolean, 9 | }; 10 | 11 | declare export default typeof usePaginatedRequest; 12 | -------------------------------------------------------------------------------- /usePromise.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare namespace usePromise { 3 | export type TUsePromiseResult = { 4 | data?: T, 5 | isLoading: boolean, 6 | error: any, 7 | }; 8 | } 9 | 10 | declare function usePromise>(callFunction?: (...args: I) => Promise, ...inputs: I): usePromise.TUsePromiseResult; 11 | 12 | export = usePromise; 13 | -------------------------------------------------------------------------------- /usePromise.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var flattenInput = require('./utils/flattenInput') 4 | 5 | function usePromise ( 6 | callFunction 7 | ) { 8 | var inputs = Array.prototype.slice.call(arguments, [1]) 9 | var state = React.useState({ 10 | isLoading: !!callFunction 11 | }) 12 | 13 | React.useEffect(function () { 14 | if (!callFunction) { 15 | return 16 | } 17 | !state[0].isLoading && state[1]({ data: state[0].data, isLoading: true }) 18 | callFunction.apply(null, inputs) 19 | .then(function (data) { 20 | state[1]({ 21 | data: data, 22 | isLoading: false 23 | }) 24 | }) 25 | .catch(function (error) { 26 | state[1]({ 27 | error: error, 28 | isLoading: false 29 | }) 30 | }) 31 | }, flattenInput(inputs)) 32 | 33 | return state[0] 34 | } 35 | 36 | module.exports = usePromise 37 | -------------------------------------------------------------------------------- /usePromise.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | type TUsePromiseResult = { 3 | data: ?T, 4 | isLoading: boolean, 5 | error: mixed, 6 | }; 7 | declare function usePromise>(callFunction: ?(...args: I) => Promise, ...inputs: I): TUsePromiseResult; 8 | declare export default typeof usePromise; 9 | -------------------------------------------------------------------------------- /utils/flattenInput.js: -------------------------------------------------------------------------------- 1 | function flattenInput () { 2 | var res = [] 3 | for (var i = 0; i < arguments.length; i++) { 4 | var input = arguments[i] 5 | if (input instanceof Array) { 6 | for (var j = 0; j < input.length; j++) { 7 | res = res.concat(flattenInput(input[j])) 8 | } 9 | } else if (typeof URL !== 'undefined' && input instanceof URL) { 10 | res = res.concat(input.toJSON()) 11 | } else if (input instanceof Object) { 12 | var keys = Object.keys(input) 13 | for (var k = 0; k < keys.length; k++) { 14 | var key = keys[k] 15 | res = res.concat([key]).concat(flattenInput(input[key])) 16 | } 17 | } else { 18 | res = res.concat(input) 19 | } 20 | } 21 | return res 22 | } 23 | 24 | module.exports = flattenInput 25 | --------------------------------------------------------------------------------