├── .eslintrc ├── .gitignore ├── .prettierignore ├── LICENSE.md ├── README.md ├── demo └── src │ └── index.js ├── nwb.config.js ├── package-lock.json ├── package.json └── src ├── FetchProvider.js ├── index.js ├── useFetch.js └── utils.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": ["react-hooks"], 4 | "rules": { 5 | "react-hooks/rules-of-hooks": "error" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Bjørnar G. Hveding 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 | # @bjornagh/use-fetch 2 | 3 | An easy-to-use React hook for doing `fetch` requests. 4 | 5 | ## Features 6 | 7 | - 1️⃣ Dedupes requests done to the same endpoint. Only one request to the same endpoint will be initiated. 8 | - 💨 Caches responses to improve speed and reduce amount of requests. 9 | - 🛀 Automatically makes new requests if URL changes. 10 | - ⚛️ Small size, with only two dependencies: `react` and `fetch-dedupe`. 11 | 12 | ## Install 13 | 14 | ```bash 15 | npm install @bjornagh/use-fetch 16 | 17 | # if yarn 18 | yarn add @bjornagh/use-fetch 19 | ``` 20 | 21 | ## Usage 22 | 23 | 1. Create a cache (new Map()) and render the `FetchProvider` at the top of your application. 24 | 25 | ```jsx 26 | import { FetchProvider } from "@bjornagh/use-fetch"; 27 | 28 | const cache = new Map(); 29 | 30 | ReactDOM.render( 31 | 32 | 33 | , 34 | document.getElementById("container") 35 | ); 36 | ``` 37 | 38 | 2. Use `useFetch` in your component 39 | 40 | ```jsx 41 | import React from "react"; 42 | import { useFetch } from "@bjornagh/use-fetch"; 43 | 44 | function MyComponent() { 45 | const { data, fetching } = useFetch({ 46 | url: "https://jsonplaceholder.typicode.com/todos" 47 | }); 48 | 49 | return ( 50 |
51 | {fetching && "Loading..."} 52 | {data && data.map(x =>
{x.title}
)} 53 |
54 | ); 55 | } 56 | ``` 57 | 58 | See more examples in the examples section [examples section ](https://github.com/bghveding/use-fetch#Examples). 59 | 60 | ## API 61 | `useFetch` accepts an object that supports the following properties 62 | 63 | | Key | Default value | Description | 64 | |------|--------------|--------------| 65 | | url | | URL to send request to | 66 | | method | GET | HTTP method | 67 | | lazy | null | Lazy mode determines if a request should be done on mount and when the request parameters change (e.g. URL) or not. When null only GET requests are initiated when mounted and if for example the URL changes. If `true` this applies to all requests regardless of HTTP method. If `false`, requests are only initiated manually by calling `doFetch`, a function returned by `useFetch`| 68 | | init | {} | See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch `init` argument for which keys are supported | 69 | | dedupeOptions | {} | See https://github.com/jamesplease/fetch-dedupe#user-content-api `dedupeOptions` argument for which keys are supported | 70 | | cacheResponse | true if read request, false if write | Cache response or not | 71 | | requestKey | null | requestKey is used as cache key and to prevent duplicate requests. Generated automatically if nothing is passed. | 72 | | cachePolicy | null | [Caching strategy](https://github.com/bghveding/use-fetch#cachepolicy) | 73 | | refreshDoFetch | Function | By default doFetch method is memoized (by `useCallback`) by using the request (url+method+body). Use this to override if you get a stale doFetch. It receives one argument, the default request key. e.g. `requestKey => requestKey + 'something'` | 74 | | onError | Function | A callback function that is called anytime a fetch fails. Receives an `Error` as only argument. Logs to console by default | 75 | | onSuccess | Function | A callback function that is called anytime a fetch succeeds. Receives a fetch [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) as only argument. Does nothing by default (noop) | 76 | 77 | Return value 78 | 79 | | Key | type | Description | 80 | |------|-------------|---------------| 81 | | response | [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) | [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) | 82 | | data | * | Request data response | 83 | | fetching | Boolean | Whether request is in-flight or not | 84 | | error | [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) | Any errors from `fetch` | 85 | | requestKey | String | The key used as cache key and to prevent duplicate requests | 86 | | doFetch | Function | A function to initiate a request manually. Takes one argument: `init`, an object that is sent to `fetch`. See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch (`init` argument for which keys are supported). Returns a promise. | 87 | 88 | NOTE: Requests with `Content-type` set to `application/json` will automatically have their body 89 | stringified (`JSON.stringify`) 90 | 91 | ### `cachePolicy` 92 | * `cache-first` - Uses response in cache if available. Makes request if not. 93 | * `cache-and-network` - Uses response in cache if available, but will also always make a new request in the background in order to refresh any stale data. 94 | * `exact-cache-and-network` - Similar to `cache-and-network`, but will only show cached response if the requests are identical (url+method+body). 95 | * `network-only` - Ignores cache and always makes a request. 96 | 97 | Read requests (GET, OPTIONS, HEAD) default to `cache-first`. 98 | 99 | Write requests (POST, DELETE, PUT, PATCH) default to `network-only`. 100 | 101 | ## Examples 102 | 103 | ### POST request and update view afterwards 104 | 105 | ```jsx 106 | function Demo() { 107 | // Fetch list of posts 108 | const posts = useFetch({ 109 | url: "https://jsonplaceholder.typicode.com/posts" 110 | }); 111 | 112 | // Prepare create request 113 | // This will not be initiated until you call `createPost.doFetch()` 114 | const createPost = useFetch({ 115 | url: "https://jsonplaceholder.typicode.com/posts", 116 | method: "POST", 117 | init: { 118 | headers: { 119 | "Content-type": "application/json" 120 | } 121 | } 122 | }); 123 | 124 | return ( 125 |
126 | 149 | 150 | {!posts.data && posts.fetching && "Loading..."} 151 | {posts.data && posts.data.map(x =>

{x.title}

)} 152 |
153 | ); 154 | } 155 | ``` 156 | 157 | ### Delay fetching using the `lazy` prop 158 | 159 | Setting the `lazy` parameter to `true` tells `useFetch` to not start requesting on mount or when 160 | the request parameters change. 161 | 162 | You can change this at any time. A common pattern where this feature is useful is when you want the 163 | user to apply some filters before you initiate a request. 164 | 165 | Below is an example where a request is delayed until a search input contains at least two characters. 166 | 167 | ```jsx 168 | function LazyDemo() { 169 | const [searchFilter, setSearchFilter] = useState(""); 170 | const posts = useFetch({ 171 | url: `/posts?search=${searchFilter}`, 172 | lazy: searchFilter.length < 2 // Request is lazy as long as the input has less than two characters 173 | }); 174 | 175 | return ( 176 |
177 | setSearchFilter(event.target.value)} /> 178 | 179 | {posts.data && posts.data.map(x =>
{x.title}
)} 180 |
181 | ); 182 | } 183 | ``` 184 | 185 | ### How to set base URL, default headers and so on 186 | 187 | Create your custom fetch hook. For example: 188 | 189 | ```jsx 190 | import { useFetch } from "@bjornagh/use-fetch"; 191 | 192 | function useCustomUseFetch({ url, init = {}, ...rest }) { 193 | // Prefix URL with API root 194 | const apiRoot = "https://my-api-url.com/"; 195 | const finalUrl = `${apiRoot}${url}`; 196 | 197 | // Set a default header 198 | const finalHeaders = { ...init.headers }; 199 | finalHeaders["Content-type"] = "application/json"; 200 | 201 | // Ensure headers are sent to fetch 202 | init.headers = finalHeaders; 203 | 204 | return useFetch({ url: finalUrl, init, ...rest }); 205 | } 206 | ``` 207 | 208 | With a custom hook you could also set up a default error handler that show a toast message for example. 209 | 210 | ## Credits 211 | 212 | This library is heavily inspired by the excellent React library [react-request](https://github.com/jamesplease/react-request) 213 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { render } from "react-dom"; 3 | import { FetchProvider, useFetch } from "../../src"; 4 | 5 | const cache = new Map(); 6 | 7 | function Demo() { 8 | const [url, setUrl] = useState("https://jsonplaceholder.typicode.com/posts"); 9 | const { data, fetching, doFetch } = useFetch({ 10 | url: url 11 | }); 12 | 13 | const createPostFetch = useFetch({ 14 | url: "https://jsonplaceholder.typicode.com/posts", 15 | method: "POST", 16 | init: { 17 | headers: { 18 | "Content-type": "application/json" 19 | } 20 | } 21 | }); 22 | 23 | return ( 24 |
25 | 41 | 42 | 47 | 48 | 53 | 54 | 57 | 58 | 68 | 69 | {fetching &&
loading....
} 70 | 71 | {data && data.map(x =>
{x.title}
)} 72 |
73 | ); 74 | } 75 | 76 | render( 77 | 78 | 79 | , 80 | document.getElementById("demo") 81 | ); 82 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bjornagh/use-fetch", 3 | "version": "0.0.12", 4 | "description": "An easy-to-use React hook for doing fetch requests", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "css", 9 | "es", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build-react-component --no-demo", 15 | "clean": "nwb clean-module && nwb clean-demo", 16 | "prepublishOnly": "npm run build", 17 | "start": "nwb serve-react-demo", 18 | "test": "nwb test-react", 19 | "test:coverage": "nwb test-react --coverage", 20 | "test:watch": "nwb test-react --server", 21 | "precommit": "pretty-quick --staged" 22 | }, 23 | "dependencies": { 24 | "fetch-dedupe": "^3.0.0" 25 | }, 26 | "peerDependencies": { 27 | "react": ">= 16.8.0" 28 | }, 29 | "devDependencies": { 30 | "babel-eslint": "^9.0.0", 31 | "eslint": "^5.13.0", 32 | "eslint-config-react-app": "^3.0.6", 33 | "eslint-plugin-flowtype": "^2.50.3", 34 | "eslint-plugin-import": "^2.16.0", 35 | "eslint-plugin-jsx-a11y": "^6.2.1", 36 | "eslint-plugin-react": "^7.12.4", 37 | "eslint-plugin-react-hooks": "^1.0.1", 38 | "nwb": "0.23.x", 39 | "prettier": "^1.16.4", 40 | "pretty-quick": "^1.10.0", 41 | "react": "^16.8.1", 42 | "react-dom": "^16.8.1" 43 | }, 44 | "author": "", 45 | "homepage": "", 46 | "license": "MIT", 47 | "repository": "https://github.com/bghveding/use-fetch", 48 | "keywords": [ 49 | "react-component", 50 | "react", 51 | "react-hooks", 52 | "hooks", 53 | "fetch" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/FetchProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | const FetchContext = React.createContext(null); 3 | 4 | export const useFetchContext = () => { 5 | const cache = useContext(FetchContext); 6 | 7 | return cache; 8 | }; 9 | 10 | function FetchProvider({ cache, children }) { 11 | return ( 12 | {children} 13 | ); 14 | } 15 | 16 | export { FetchProvider }; 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { useFetch } from "./useFetch"; 2 | export { FetchProvider, useFetchContext } from "./FetchProvider"; 3 | -------------------------------------------------------------------------------- /src/useFetch.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useReducer, useCallback } from "react"; 2 | import { fetchDedupe, getRequestKey } from "fetch-dedupe"; 3 | import { useFetchContext } from "./FetchProvider"; 4 | import { stringifyIfJSON, isReadRequest } from "./utils"; 5 | 6 | const CACHE_POLICIES = { 7 | NETWORK_ONLY: "network-only", 8 | CACHE_AND_NETWORK: "cache-and-network", 9 | EXACT_CACHE_AND_NETWORK: "exact-cache-and-network", 10 | CACHE_FIRST: "cache-first" 11 | }; 12 | 13 | const getDefaultCacheStrategy = method => { 14 | method = method.toUpperCase(); 15 | 16 | if (method === "GET" || method === "HEAD" || method === "OPTIONS") { 17 | return CACHE_POLICIES.CACHE_FIRST; 18 | } 19 | 20 | return CACHE_POLICIES.NETWORK_ONLY; 21 | }; 22 | 23 | const defaultOnError = error => console.error(error); 24 | const defaultOnSuccess = () => null; 25 | 26 | function reducer(state, action) { 27 | switch (action.type) { 28 | case "in-flight": { 29 | // Avoid updating state unnecessarily 30 | // By returning unchanged state React won't re-render 31 | if (state.fetching === true) { 32 | return state; 33 | } 34 | 35 | return { 36 | ...state, 37 | fetching: true 38 | }; 39 | } 40 | 41 | case "response": { 42 | return { 43 | ...state, 44 | error: null, 45 | fetching: action.payload.fetching, 46 | response: action.payload.response 47 | }; 48 | } 49 | 50 | case "error": 51 | return { 52 | ...state, 53 | fetching: false, 54 | error: action.payload.error 55 | }; 56 | 57 | default: 58 | return state; 59 | } 60 | } 61 | 62 | const defaultRefreshDoFetch = requestKey => requestKey; 63 | 64 | function useFetch({ 65 | url, 66 | method = "GET", 67 | lazy = null, 68 | requestKey = null, 69 | init = {}, 70 | dedupeOptions = {}, 71 | cachePolicy = null, 72 | cacheResponse = null, 73 | onError = defaultOnError, 74 | onSuccess = defaultOnSuccess, 75 | refreshDoFetch = defaultRefreshDoFetch 76 | }) { 77 | const responseCache = useFetchContext(); 78 | 79 | const abortControllerRef = useRef(); 80 | 81 | // Default GET|HEAD|OPTIONS requests to non-lazy (automatically request onMount) 82 | // all else lazy (only requested when doFetch is called) 83 | const isLazy = lazy == null ? !isReadRequest(method) : lazy; 84 | 85 | const cacheStrategy = 86 | cachePolicy === null ? getDefaultCacheStrategy(method) : cachePolicy; 87 | 88 | const [state, dispatch] = useReducer(reducer, { 89 | response: null, 90 | fetching: !isLazy, 91 | error: null 92 | }); 93 | 94 | // Builds a key based on URL, method and headers. 95 | // requestKey is used to determine if the request parameters have changed 96 | // and as key in the response cache. 97 | const finalRequestKey = requestKey 98 | ? requestKey 99 | : getRequestKey({ url, method: method.toUpperCase(), ...init }); 100 | 101 | function setFetching() { 102 | dispatch({ type: "in-flight" }); 103 | } 104 | 105 | function setResponse(response, fetching = false) { 106 | dispatch({ type: "response", payload: { response, fetching } }); 107 | } 108 | 109 | function setError(error) { 110 | dispatch({ type: "error", payload: { error } }); 111 | } 112 | 113 | function cancelRunningRequest() { 114 | if (abortControllerRef.current) { 115 | // Cancel current request 116 | abortControllerRef.current.abort(); 117 | } 118 | } 119 | 120 | function shouldCacheResponse() { 121 | if (cacheResponse !== null) { 122 | return cacheResponse; 123 | } 124 | 125 | return isReadRequest(method); 126 | } 127 | 128 | const doFetch = useCallback( 129 | (doFetchInit = {}, doFetchDedupeOptions = {}) => { 130 | cancelRunningRequest(); 131 | 132 | abortControllerRef.current = new AbortController(); 133 | 134 | setFetching(true); 135 | 136 | const finalInit = { 137 | ...init, 138 | ...doFetchInit 139 | }; 140 | 141 | const finalDedupeOptions = { 142 | ...dedupeOptions, 143 | ...doFetchDedupeOptions 144 | }; 145 | 146 | return fetchDedupe( 147 | finalInit.url || url, 148 | { 149 | ...finalInit, 150 | method, 151 | signal: abortControllerRef.current.signal, 152 | body: finalInit.body ? stringifyIfJSON(finalInit) : undefined 153 | }, 154 | finalDedupeOptions 155 | ) 156 | .then(response => { 157 | if (!response.ok) { 158 | setError(response); 159 | onError(response); 160 | } else { 161 | if (shouldCacheResponse()) { 162 | responseCache.set(finalRequestKey, response); 163 | } 164 | setResponse(response); 165 | onSuccess(response); 166 | } 167 | 168 | return response; 169 | }) 170 | .catch(error => { 171 | if (!abortControllerRef.current.signal.aborted) { 172 | setError(error); 173 | } 174 | 175 | onError(error); 176 | 177 | return error; 178 | }) 179 | .finally(() => { 180 | // Remove the abort controller now that the request is done 181 | abortControllerRef.current = null; 182 | }); 183 | }, 184 | [refreshDoFetch(finalRequestKey)] 185 | ); 186 | 187 | // Start requesting onMount if not lazy 188 | // Start requesting if isLazy goes from true to false 189 | // Start requesting every time the request key changes (i.e. URL, method, init.body or init.responseType) if not lazy 190 | useEffect(() => { 191 | // Do not start request automatically when in lazy mode 192 | if (isLazy === true) { 193 | return; 194 | } 195 | 196 | const cachedResponse = responseCache.get(finalRequestKey); 197 | 198 | // Return cached response if it exists 199 | if (cacheStrategy === CACHE_POLICIES.CACHE_FIRST && cachedResponse) { 200 | onSuccess(cachedResponse); 201 | return setResponse(cachedResponse); 202 | } 203 | 204 | // Return any cached data immediately, but initiate request anyway in order to refresh any stale data 205 | if (cacheStrategy === CACHE_POLICIES.CACHE_AND_NETWORK && cachedResponse) { 206 | onSuccess(cachedResponse); 207 | setResponse(cachedResponse, true); 208 | } 209 | 210 | // Almost like 'cache-and-network', but this clears the previous request key's response 211 | // and only shows a cached response if the request keys are identical 212 | // In other words it won't show the previous request key's data while fetching 213 | if (cacheStrategy === CACHE_POLICIES.EXACT_CACHE_AND_NETWORK) { 214 | if (cachedResponse) { 215 | onSuccess(cachedResponse); 216 | setResponse(cachedResponse, true); 217 | } else { 218 | setResponse(null); 219 | } 220 | } 221 | 222 | // Always fetch new data if request params change 223 | if (cacheStrategy === CACHE_POLICIES.NETWORK_ONLY) { 224 | // Reset response state since we are only interested in any cached response from previous request 225 | setResponse(null); 226 | } 227 | 228 | doFetch(init); 229 | }, [finalRequestKey, isLazy]); 230 | 231 | // Cancel any running request when unmounting to avoid updating state after component has unmounted 232 | // This can happen if a request's promise resolves after component unmounts 233 | useEffect(() => { 234 | return () => { 235 | cancelRunningRequest(); 236 | }; 237 | }, []); 238 | 239 | return { 240 | response: state.response, 241 | data: state.response ? state.response.data : null, 242 | fetching: state.fetching, 243 | error: state.error, 244 | requestKey: finalRequestKey, 245 | doFetch 246 | }; 247 | } 248 | 249 | export { useFetch }; 250 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const getHeader = (headers, keyToFind) => { 2 | if (!headers) { 3 | return null; 4 | } 5 | 6 | // Headers' get() is case insensitive 7 | if (headers instanceof Headers) { 8 | return headers.get(keyToFind); 9 | } 10 | 11 | const keyToFindLowercase = keyToFind.toLowerCase(); 12 | // Convert keys to lowerCase so we don't run into case sensitivity issues 13 | const headerKey = Object.keys(headers).find( 14 | headerKey => headerKey.toLowerCase() === keyToFindLowercase 15 | ); 16 | 17 | return headerKey ? headers[headerKey] : null; 18 | }; 19 | 20 | export const stringifyIfJSON = fetchOptions => { 21 | const contentType = getHeader(fetchOptions.headers, "Content-Type"); 22 | 23 | if (contentType && contentType.indexOf("application/json") !== -1) { 24 | return JSON.stringify(fetchOptions.body); 25 | } 26 | 27 | return fetchOptions.body; 28 | }; 29 | 30 | export const isReadRequest = method => { 31 | method = method.toUpperCase(); 32 | 33 | return method === "GET" || method === "HEAD" || method === "OPTIONS"; 34 | }; 35 | --------------------------------------------------------------------------------