├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .npmignore ├── .npmrc ├── .storybook ├── config.js └── webpack.config.js ├── LICENSE ├── README.md ├── docs ├── 3.6ca5e2ce4143942873e3.bundle.js ├── 3.6ca5e2ce4143942873e3.bundle.js.map ├── favicon.ico ├── iframe.html ├── index.html ├── main.6ca5e2ce4143942873e3.bundle.js ├── main.6ca5e2ce4143942873e3.bundle.js.map ├── main.de7e00959fb6417507d4.bundle.js ├── runtime~main.4d6118575687c0a0f1fb.bundle.js ├── runtime~main.6ca5e2ce4143942873e3.bundle.js ├── runtime~main.6ca5e2ce4143942873e3.bundle.js.map ├── sb_dll │ ├── storybook_ui-manifest.json │ ├── storybook_ui_dll.LICENCE │ └── storybook_ui_dll.js ├── vendors~main.6ca5e2ce4143942873e3.bundle.js ├── vendors~main.6ca5e2ce4143942873e3.bundle.js.map └── vendors~main.fa696c9ccdf84b1bb81b.bundle.js ├── examples ├── basic │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── manifest.json │ └── src │ │ └── index.js ├── render-as-you-fetch │ ├── basic │ │ ├── .env │ │ ├── package.json │ │ ├── public │ │ │ └── index.html │ │ └── src │ │ │ ├── MovieDetails.js │ │ │ ├── MovieList.js │ │ │ ├── MovieListButton.js │ │ │ ├── api.js │ │ │ └── index.js │ └── with-resources │ │ ├── .env │ │ ├── package.json │ │ ├── public │ │ └── index.html │ │ └── src │ │ ├── MovieDetails.js │ │ ├── MovieList.js │ │ ├── MovieListButton.js │ │ ├── api.js │ │ ├── index.js │ │ └── resources.js ├── top-movies │ ├── .env │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ ├── MovieDetails.js │ │ ├── MovieList.js │ │ ├── MovieListButton.js │ │ ├── api.js │ │ └── index.js ├── with-resources │ ├── .env │ ├── package.json │ ├── public │ │ └── index.html │ └── src │ │ └── index.js └── with-typescript │ ├── .env │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json │ ├── src │ ├── index.tsx │ └── react-app-env.d.ts │ └── tsconfig.json ├── jest.config.js ├── jest.setup.js ├── package.json ├── rollup.config.js ├── src ├── Loads.tsx ├── LoadsConfig.tsx ├── __stories__ │ ├── api.js │ └── index.stories.js ├── __tests__ │ ├── __snapshots__ │ │ └── useLoads.test.tsx.snap │ └── useLoads.test.tsx ├── cache.ts ├── constants.ts ├── createResource.ts ├── hooks │ ├── useDetectMounted.ts │ ├── useInterval.ts │ ├── usePrevious.ts │ └── useTimeout.ts ├── index.ts ├── preload.ts ├── types.ts ├── useCache.ts ├── useDeferredLoads.ts ├── useGetStates.ts ├── useLoads.ts └── utils.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-object-rest-spread", 10 | "@babel/plugin-syntax-dynamic-import" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@medipass/react-medipass"], 3 | "rules": { 4 | "react/jsx-handler-names": "off" 5 | }, 6 | "overrides": [ 7 | { 8 | "files": ["**/*.ts", "**/*.tsx"], 9 | "parser": "typescript-eslint-parser", 10 | "plugins": ["typescript"], 11 | "rules": { 12 | "no-undef": "off", 13 | "no-unused-vars": "off" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | yarn-error.log 4 | lib/ 5 | es/ 6 | umd/ 7 | ts/ 8 | dist/ 9 | coverage/ 10 | TODO.md 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { addDecorator, configure } from '@storybook/react'; 3 | import { setConfig } from 'react-hot-loader'; 4 | import { Box, ThemeProvider } from 'fannypack'; 5 | import store from 'store'; 6 | 7 | // automatically import all files ending in *.stories.js 8 | const req = require.context('../src/__stories__', true, /.stories.js$/); 9 | function loadStories() { 10 | req.keys().forEach(filename => req(filename)); 11 | } 12 | 13 | const Decorator = storyFn => ( 14 | 15 | 16 | {storyFn()} 17 | 18 | 19 | ); 20 | addDecorator(Decorator); 21 | 22 | setConfig({ pureSFC: true }); 23 | configure(loadStories, module); 24 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | loader: require.resolve('babel-loader') 5 | }); 6 | config.resolve.extensions.push('.ts', '.tsx'); 7 | return config; 8 | }; 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) jxom (jxom.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /docs/3.6ca5e2ce4143942873e3.bundle.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[3],{913:function(module,exports,__webpack_require__){var __WEBPACK_AMD_DEFINE_RESULT__;!function(){function aa(a,b,c){return a.call.apply(a.bind,arguments)}function ba(a,b,c){if(!a)throw Error();if(2=b.f?e():a.fonts.load(function fa(a){return H(a)+" "+a.f+"00 300px "+I(a.c)}(b.a),b.h).then((function(a){1<=a.length?d():setTimeout(f,25)}),(function(){e()}))}()})),e=null,f=new Promise((function(a,d){e=setTimeout(d,b.f)}));Promise.race([f,d]).then((function(){e&&(clearTimeout(e),e=null),b.g(b.a)}),(function(){b.j(b.a)}))};var R={D:"serif",C:"sans-serif"},S=null;function T(){if(null===S){var a=/AppleWebKit\/([0-9]+)(?:\.([0-9]+))/.exec(window.navigator.userAgent);S=!!a&&(536>parseInt(a[1],10)||536===parseInt(a[1],10)&&11>=parseInt(a[2],10))}return S}function la(a,b,c){for(var d in R)if(R.hasOwnProperty(d)&&b===a.f[R[d]]&&c===a.f[R[d]])return!0;return!1}function U(a){var d,b=a.g.a.offsetWidth,c=a.h.a.offsetWidth;(d=b===a.f.serif&&c===a.f["sans-serif"])||(d=T()&&la(a,b,c)),d?q()-a.A>=a.w?T()&&la(a,b,c)&&(null===a.u||a.u.hasOwnProperty(a.a.c))?V(a,a.v):V(a,a.B):function ma(a){setTimeout(p((function(){U(this)}),a),50)}(a):V(a,a.v)}function V(a,b){setTimeout(p((function(){v(this.g.a),v(this.h.a),v(this.j.a),v(this.m.a),b(this.a)}),a),0)}function W(a,b,c){this.c=a,this.a=b,this.f=0,this.m=this.j=!1,this.s=c}Q.prototype.start=function(){this.f.serif=this.j.a.offsetWidth,this.f["sans-serif"]=this.m.a.offsetWidth,this.A=q(),U(this)};var X=null;function na(a){0==--a.f&&a.j&&(a.m?((a=a.a).g&&w(a.f,[a.a.c("wf","active")],[a.a.c("wf","loading"),a.a.c("wf","inactive")]),K(a,"active")):L(a.a))}function oa(a){this.j=a,this.a=new ja,this.h=0,this.f=this.g=!0}function qa(a,b,c,d,e){var f=0==--a.h;(a.f||a.g)&&setTimeout((function(){var a=e||null,m=d||{};if(0===c.length&&f)L(b.a);else{b.f+=c.length,f&&(b.j=f);var h,l=[];for(h=0;hStorybook

No Preview

Sorry, but you either have no stories or none are selected somehow.

  • Please check the Storybook config.
  • Try reloading the page.

If the problem persists, check the browser console, or the terminal you've run Storybook from.

-------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Storybook
-------------------------------------------------------------------------------- /docs/main.6ca5e2ce4143942873e3.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"main.6ca5e2ce4143942873e3.bundle.js","sources":["webpack:///main.6ca5e2ce4143942873e3.bundle.js"],"mappings":"AAAA","sourceRoot":""} -------------------------------------------------------------------------------- /docs/main.de7e00959fb6417507d4.bundle.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[0],{320:function(n,o,p){p(321),n.exports=p(415)},342:function(n,o){}},[[320,1,2]]]); -------------------------------------------------------------------------------- /docs/runtime~main.4d6118575687c0a0f1fb.bundle.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];c 34 | * 35 | * Copyright (c) 2014-2017, Jon Schlinkert. 36 | * Released under the MIT License. 37 | */ 38 | 39 | /*! 40 | * https://github.com/paulmillr/es6-shim 41 | * @license es6-shim Copyright 2013-2016 by Paul Miller (http://paulmillr.com) 42 | * and contributors, MIT License 43 | * es6-shim: v0.35.4 44 | * see https://github.com/paulmillr/es6-shim/blob/0.35.3/LICENSE 45 | * Details and documentation: 46 | * https://github.com/paulmillr/es6-shim/ 47 | */ 48 | 49 | /** @license React v16.8.1 50 | * react.production.min.js 51 | * 52 | * Copyright (c) Facebook, Inc. and its affiliates. 53 | * 54 | * This source code is licensed under the MIT license found in the 55 | * LICENSE file in the root directory of this source tree. 56 | */ 57 | 58 | /** @license React v0.13.1 59 | * scheduler.production.min.js 60 | * 61 | * Copyright (c) Facebook, Inc. and its affiliates. 62 | * 63 | * This source code is licensed under the MIT license found in the 64 | * LICENSE file in the root directory of this source tree. 65 | */ 66 | 67 | /* 68 | object-assign 69 | (c) Sindre Sorhus 70 | @license MIT 71 | */ 72 | 73 | /** @license React v16.8.1 74 | * react-dom.production.min.js 75 | * 76 | * Copyright (c) Facebook, Inc. and its affiliates. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE file in the root directory of this source tree. 80 | */ 81 | 82 | /*! 83 | Copyright (c) 2016 Jed Watson. 84 | Licensed under the MIT License (MIT), see 85 | http://jedwatson.github.io/classnames 86 | */ 87 | -------------------------------------------------------------------------------- /docs/vendors~main.6ca5e2ce4143942873e3.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"vendors~main.6ca5e2ce4143942873e3.bundle.js","sources":["webpack:///vendors~main.6ca5e2ce4143942873e3.bundle.js"],"mappings":"AAAA;;;;;AAwk6BA;;;;;;;;;;;;;;;;;;;;;;;;;AAipJA;;;;;AA63SA;;;;;;AAsZA;;;;;AA+/JA;;;;;AAkkEA;;;;;;;;;AAukBA;;;;;;;;AA2wNA;;;;;;AAmoBA;;;;;;;;AAo7QA;;;;;;;;;AA6RA;;;;;;;;AAscA;;;;;;;AA2vlBA","sourceRoot":""} -------------------------------------------------------------------------------- /examples/basic/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "basic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "0.19.0", 7 | "fannypack": "4.19.22", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-loads": "8.0.5", 11 | "react-scripts": "3.0.1" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/basic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jxom/react-loads/20d6a9e583891377e0d73532702664bfde1a0350/examples/basic/public/favicon.ico -------------------------------------------------------------------------------- /examples/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/basic/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/basic/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import axios from 'axios'; 4 | import { Alert, Container, Heading, List, Spinner, ThemeProvider } from 'fannypack'; 5 | import { useLoads } from 'react-loads'; 6 | 7 | function App() { 8 | const getUsers = React.useCallback(async () => { 9 | const response = await axios.get('https://jsonplaceholder.typicode.com/users'); 10 | return response.data; 11 | }, []); 12 | const usersRecord = useLoads('users', getUsers); 13 | const users = usersRecord.response || []; 14 | 15 | return ( 16 | 17 | 18 | Users 19 | {usersRecord.isPending && } 20 | {usersRecord.isResolved && ( 21 | 22 | {users.map(user => ( 23 | {user.name} 24 | ))} 25 | 26 | )} 27 | {usersRecord.isRejected && Error! {usersRecord.error.message}} 28 | 29 | 30 | ); 31 | } 32 | 33 | ReactDOM.render(, document.getElementById('root')); 34 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loads", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.js", 7 | "dependencies": { 8 | "axios": "0.19.0", 9 | "fannypack": "4.19.23", 10 | "react": "0.0.0-experimental-f6b8d31a7", 11 | "react-dom": "0.0.0-experimental-f6b8d31a7", 12 | "react-loads": "8.3.8", 13 | "react-router": "5.0.1", 14 | "react-router-dom": "5.0.1", 15 | "react-scripts": "3.0.1", 16 | "scheduler": "0.14.0", 17 | "store": "2.0.12" 18 | }, 19 | "devDependencies": { 20 | "typescript": "3.3.3" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test --env=jsdom", 26 | "eject": "react-scripts eject" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/src/MovieDetails.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Blockquote, 5 | Button, 6 | Columns, 7 | Column, 8 | Flex, 9 | Heading, 10 | Image, 11 | Label, 12 | LayoutSet, 13 | Rating, 14 | Text 15 | } from 'fannypack'; 16 | 17 | export default function MovieDetails(props) { 18 | const { movieLoaders, onClickBack } = props; 19 | 20 | const movieRecord = movieLoaders.movie.useLoads(); 21 | const movie = movieRecord.response || {}; 22 | 23 | const movieReviewsRecord = movieLoaders.reviews.useLoads(); 24 | const reviews = movieReviewsRecord.response || {}; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | {movie.title} 33 | 34 | 35 | 36 | {movie.year} | Rated {movie.rated} | {movie.runtime} 37 | 38 | 39 | 40 | 41 | 42 | 43 | {movie.plot} 44 | 45 | 46 | 47 | {movie.genre} 48 | 49 | 50 | 51 | {movie.actors} 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Reviews 61 | 62 | {reviews.length === 0 && No reviews.} 63 | {reviews.length > 0 && 64 | reviews.map(review => ( 65 | 66 |
67 | 68 | {review.comment} 69 |
70 | 71 | {review.reviewer} 72 | 73 |
74 |
75 | ))} 76 |
77 |
78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/src/MovieList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Heading, LayoutSet } from 'fannypack'; 3 | import * as Loads from 'react-loads'; 4 | 5 | import * as api from './api'; 6 | import MovieListButton from './MovieListButton'; 7 | 8 | export default function MovieList(props) { 9 | const { loadingMovieId, onSelectMovie } = props; 10 | 11 | const moviesRecord = Loads.useLoads('movies', api.getMovies); 12 | const movies = moviesRecord.response || []; 13 | 14 | return ( 15 | 16 | Jake's Top Movies 17 | 18 | {movies.map(movie => ( 19 | onSelectMovie(movie)} 24 | /> 25 | ))} 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/src/MovieListButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Card, Text, palette, styled } from 'fannypack'; 3 | 4 | const Button = styled(Card)` 5 | cursor: pointer; 6 | display: flex; 7 | justify-content: space-between; 8 | text-decoration: none; 9 | 10 | &:hover { 11 | background-color: ${palette('white600')}; 12 | } 13 | `; 14 | 15 | export default function MovieListButton({ movie, isLoading, ...props }) { 16 | return ( 17 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/src/api.js: -------------------------------------------------------------------------------- 1 | const movies = [ 2 | { 3 | id: 1, 4 | title: 'Movie 43', 5 | year: '2013', 6 | rated: 'R', 7 | released: '25 Jan 2013', 8 | runtime: '94 min', 9 | genre: 'Comedy, Horror, Thriller', 10 | director: 11 | 'Elizabeth Banks, Steven Brill, Steve Carr, Rusty Cundieff, James Duffy, Griffin Dunne, Peter Farrelly, Patrik Forsberg, Will Graham, James Gunn, Brett Ratner, Jonathan van Tulleken, Bob Odenkirk', 12 | writer: 13 | "Rocky Russo, Jeremy Sosenko, Ricky Blitt, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Bill O'Malley (story), Will Graham, Jack Kukoda, Rocky Russo, Jeremy Sosenko, Matt Portenoy, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Claes Kjellstrom (story), Jonas Wittenmark (story), Tobias Carlson (story), Will Carlough, Jonathan van Tulleken, Elizabeth Shapiro, Patrik Forsberg, Olle Sarri, Jacob Fleisher, Greg Pritikin, Rocky Russo, Jeremy Sosenko, James Gunn", 14 | actors: 'Dennis Quaid, Greg Kinnear, Common, Charlie Saxton', 15 | plot: 16 | 'A series of interconnected short films follows a washed-up producer as he pitches insane story lines featuring some of the biggest stars in Hollywood.', 17 | language: 'English', 18 | country: 'USA', 19 | awards: '4 wins & 5 nominations.', 20 | poster: 'https://m.media-amazon.com/images/M/MV5BMTg4NzQ3NDM1Nl5BMl5BanBnXkFtZTcwNjEzMjM3OA@@._V1_SX300.jpg', 21 | ratings: [ 22 | { 23 | Source: 'Internet Movie Database', 24 | Value: '4.3/10' 25 | }, 26 | { 27 | Source: 'Rotten Tomatoes', 28 | Value: '5%' 29 | }, 30 | { 31 | Source: 'Metacritic', 32 | Value: '18/100' 33 | } 34 | ], 35 | metascore: '18', 36 | imdbRating: '4.3', 37 | imdbVotes: '92,291', 38 | imdbID: 'tt1333125', 39 | type: 'movie', 40 | dvd: '18 Jun 2013', 41 | boxOffice: '$8,700,000', 42 | production: 'Relativity Media', 43 | website: 'http://www.facebook.com/WhatIsMovie43' 44 | }, 45 | { 46 | id: 2, 47 | title: 'Scary Movie', 48 | year: '2000', 49 | rated: 'R', 50 | released: '07 Jul 2000', 51 | runtime: '88 min', 52 | genre: 'Comedy', 53 | director: 'Keenen Ivory Wayans', 54 | writer: 'Shawn Wayans, Marlon Wayans, Buddy Johnson, Phil Beauman, Jason Friedberg, Aaron Seltzer', 55 | actors: 'Carmen Electra, Dave Sheridan, Frank B. Moore, Giacomo Baessato', 56 | plot: 57 | 'A year after disposing of the body of a man they accidentally killed, a group of dumb teenagers are stalked by a bumbling serial killer.', 58 | language: 'English', 59 | country: 'USA', 60 | awards: '7 wins & 5 nominations.', 61 | poster: 62 | 'https://m.media-amazon.com/images/M/MV5BMGEzZjdjMGQtZmYzZC00N2I4LThiY2QtNWY5ZmQ3M2ExZmM4XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg', 63 | ratings: [ 64 | { 65 | Source: 'Internet Movie Database', 66 | Value: '6.2/10' 67 | }, 68 | { 69 | Source: 'Rotten Tomatoes', 70 | Value: '53%' 71 | }, 72 | { 73 | Source: 'Metacritic', 74 | Value: '48/100' 75 | } 76 | ], 77 | metascore: '48', 78 | imdbRating: '6.2', 79 | imdbVotes: '213,907', 80 | imdbID: 'tt0175142', 81 | type: 'movie', 82 | dvd: '12 Dec 2000', 83 | boxOffice: 'N/A', 84 | production: 'Dimension Films', 85 | website: 'http://www.scarymovie.com' 86 | }, 87 | { 88 | id: 3, 89 | title: 'Jackass 3D', 90 | year: '2010', 91 | rated: 'R', 92 | released: '15 Oct 2010', 93 | runtime: '94 min', 94 | genre: 'Documentary, Action, Comedy', 95 | director: 'Jeff Tremaine', 96 | writer: 97 | "Jeff Tremaine (concepts by), Johnny Knoxville (concepts by), Bam Margera (concepts by), Steve-O (concepts by), Chris Pontius (concepts by), Ryan Dunn (concepts by), Jason 'Wee Man' Acuña (concepts by), Preston Lacy (concepts by), Ehren McGhehey (concepts by), Dave England (concepts by), Spike Jonze (concepts by), Loomis Fall (concepts by), Barry Owen Smoler (concepts by), The Dudesons (concepts by), Dave Carnie (concepts by), Mike Kassak (concepts by), Madison Clapp (concepts by), Knate Lee (concepts by), Derek Freda (concepts by), Trip Taylor (concepts by), Sean Cliver (concepts by), Dimitry Elyashkevich (concepts by), J.P. Blackmon (concepts by), Rick Kosick (concepts by), Harrison Stone", 98 | actors: 'Johnny Knoxville, Bam Margera, Ryan Dunn, Steve-O', 99 | plot: 100 | 'Johnny Knoxville and company return for the third installment of their TV show spin-off, where dangerous stunts and explicit public displays rule.', 101 | language: 'English', 102 | country: 'USA', 103 | awards: '1 win & 4 nominations.', 104 | poster: 'https://m.media-amazon.com/images/M/MV5BMjI3NTQ1NTE4OV5BMl5BanBnXkFtZTcwMzEzMzA3NA@@._V1_SX300.jpg', 105 | ratings: [ 106 | { 107 | Source: 'Internet Movie Database', 108 | Value: '7.0/10' 109 | }, 110 | { 111 | Source: 'Rotten Tomatoes', 112 | Value: '65%' 113 | }, 114 | { 115 | Source: 'Metacritic', 116 | Value: '56/100' 117 | } 118 | ], 119 | metascore: '56', 120 | imdbRating: '7.0', 121 | imdbVotes: '53,134', 122 | imdbID: 'tt1116184', 123 | type: 'movie', 124 | dvd: '08 Mar 2011', 125 | boxOffice: '$117,222,007', 126 | production: 'Paramount Pictures/MTV Films', 127 | website: 'http://www.jackassmovie.com/' 128 | }, 129 | { 130 | id: 4, 131 | title: 'Titanic', 132 | year: '1997', 133 | rated: 'PG-13', 134 | released: '19 Dec 1997', 135 | runtime: '194 min', 136 | genre: 'Drama, Romance', 137 | director: 'James Cameron', 138 | writer: 'James Cameron', 139 | actors: 'Leonardo DiCaprio, Kate Winslet, Billy Zane, Kathy Bates', 140 | plot: 141 | 'A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.', 142 | language: 'English, Swedish, Italian', 143 | country: 'USA', 144 | awards: 'Won 11 Oscars. Another 111 wins & 77 nominations.', 145 | poster: 146 | 'https://m.media-amazon.com/images/M/MV5BMDdmZGU3NDQtY2E5My00ZTliLWIzOTUtMTY4ZGI1YjdiNjk3XkEyXkFqcGdeQXVyNTA4NzY1MzY@._V1_SX300.jpg', 147 | ratings: [ 148 | { 149 | Source: 'Internet Movie Database', 150 | Value: '7.8/10' 151 | }, 152 | { 153 | Source: 'Rotten Tomatoes', 154 | Value: '89%' 155 | }, 156 | { 157 | Source: 'Metacritic', 158 | Value: '75/100' 159 | } 160 | ], 161 | metascore: '75', 162 | imdbRating: '7.8', 163 | imdbVotes: '951,902', 164 | imdbID: 'tt0120338', 165 | type: 'movie', 166 | dvd: '10 Sep 2012', 167 | boxOffice: 'N/A', 168 | production: 'Paramount Pictures', 169 | website: 'http://www.titanicmovie.com/' 170 | } 171 | ]; 172 | 173 | const reviews = [ 174 | { 175 | id: 1, 176 | movieId: 1, 177 | comment: 'Absolutely terrible, why would anyone watch this piece of junk?', 178 | reviewer: 'Dan Wheeler', 179 | rating: 3 180 | }, 181 | { 182 | id: 2, 183 | movieId: 1, 184 | comment: 'I like the idea of this movie. It is inspiring.', 185 | reviewer: 'Michael Abramov', 186 | rating: 5 187 | }, 188 | { 189 | id: 3, 190 | movieId: 2, 191 | comment: 'I feel like you need to take some sort of drug before you see this movie.', 192 | reviewer: 'Tom Trombone', 193 | rating: 4 194 | }, 195 | { 196 | id: 4, 197 | movieId: 3, 198 | comment: 'This is awesome.', 199 | reviewer: 'Paul Plots', 200 | rating: 9 201 | } 202 | ]; 203 | 204 | export async function getMovies() { 205 | console.log('fetchmovies'); 206 | return new Promise(res => setTimeout(() => res(movies), 1000)); 207 | } 208 | 209 | export async function getMovie(movieId) { 210 | console.log('fetchmovie', movieId); 211 | 212 | const movie = movies.find(movie => movie.id === movieId); 213 | return new Promise((res, rej) => setTimeout(() => res(movie), 1000)); 214 | } 215 | 216 | export async function getReviewsByMovieId(movieId) { 217 | console.log('fetchmoviereview', movieId); 218 | 219 | const movieReviews = reviews.filter(review => review.movieId === movieId); 220 | return new Promise(res => setTimeout(() => res(movieReviews), 2000)); 221 | } 222 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/basic/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Container, ThemeProvider } from 'fannypack'; 4 | import * as Loads from 'react-loads'; 5 | 6 | import * as api from './api'; 7 | import MovieDetails from './MovieDetails'; 8 | import MovieList from './MovieList'; 9 | 10 | const loadsConfig = { 11 | suspense: true 12 | }; 13 | 14 | function getMovieLoaders(movieId) { 15 | return { 16 | movie: Loads.preload('movie', api.getMovie, { variables: [movieId] }), 17 | reviews: Loads.preload('movieReviews', api.getReviewsByMovieId, { variables: [movieId] }) 18 | }; 19 | } 20 | 21 | function App() { 22 | const [startTransition] = React.useTransition({ timeoutMs: 1000 }); 23 | const [movieLoaders, setMovieLoaders] = React.useState(); 24 | const [currentMovieId, setCurrentMovieId] = React.useState(); 25 | 26 | function handleClickBack() { 27 | setCurrentMovieId(); 28 | setMovieLoaders(); 29 | } 30 | 31 | function handleSelectMovie(movie) { 32 | setCurrentMovieId(movie.id); 33 | 34 | startTransition(() => { 35 | const movieLoaders = getMovieLoaders(movie.id); 36 | setMovieLoaders(movieLoaders); 37 | }); 38 | } 39 | 40 | return ( 41 | 42 | 43 | 44 | loading...}> 45 | {movieLoaders ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | ReactDOM.createRoot(document.getElementById('root')).render(); 58 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loads", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.js", 7 | "dependencies": { 8 | "axios": "0.19.0", 9 | "fannypack": "4.19.23", 10 | "react": "0.0.0-experimental-f6b8d31a7", 11 | "react-dom": "0.0.0-experimental-f6b8d31a7", 12 | "react-loads": "8.3.8", 13 | "react-router": "5.0.1", 14 | "react-router-dom": "5.0.1", 15 | "react-scripts": "3.0.1", 16 | "scheduler": "0.14.0", 17 | "store": "2.0.12" 18 | }, 19 | "devDependencies": { 20 | "typescript": "3.3.3" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test --env=jsdom", 26 | "eject": "react-scripts eject" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/MovieDetails.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Blockquote, 5 | Button, 6 | Columns, 7 | Column, 8 | Flex, 9 | Heading, 10 | Image, 11 | Label, 12 | LayoutSet, 13 | Rating, 14 | Text 15 | } from 'fannypack'; 16 | 17 | export default function MovieDetails(props) { 18 | const { movieLoaders, onClickBack } = props; 19 | 20 | const movieRecord = movieLoaders.movie.useLoads(); 21 | const movie = movieRecord.response || {}; 22 | 23 | const movieReviewsRecord = movieLoaders.reviews.useLoads(); 24 | const reviews = movieReviewsRecord.response || {}; 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | {movie.title} 33 | 34 | 35 | 36 | {movie.year} | Rated {movie.rated} | {movie.runtime} 37 | 38 | 39 | 40 | 41 | 42 | 43 | {movie.plot} 44 | 45 | 46 | 47 | {movie.genre} 48 | 49 | 50 | 51 | {movie.actors} 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Reviews 61 | 62 | {reviews.length === 0 && No reviews.} 63 | {reviews.length > 0 && 64 | reviews.map(review => ( 65 | 66 |
67 | 68 | {review.comment} 69 |
70 | 71 | {review.reviewer} 72 | 73 |
74 |
75 | ))} 76 |
77 |
78 |
79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/MovieList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Heading, LayoutSet } from 'fannypack'; 3 | 4 | import MovieListButton from './MovieListButton'; 5 | import { moviesResource } from './resources'; 6 | 7 | export default function MovieList(props) { 8 | const { loadingMovieId, onSelectMovie } = props; 9 | 10 | const moviesRecord = moviesResource.useLoads({ suspense: true }); 11 | const movies = moviesRecord.response || []; 12 | 13 | return ( 14 | 15 | Jake's Top Movies 16 | 17 | {movies.map(movie => ( 18 | onSelectMovie(movie)} 23 | /> 24 | ))} 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/MovieListButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Card, Text, palette, styled } from 'fannypack'; 3 | 4 | const Button = styled(Card)` 5 | cursor: pointer; 6 | display: flex; 7 | justify-content: space-between; 8 | text-decoration: none; 9 | 10 | &:hover { 11 | background-color: ${palette('white600')}; 12 | } 13 | `; 14 | 15 | export default function MovieListButton({ movie, isLoading, ...props }) { 16 | return ( 17 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/api.js: -------------------------------------------------------------------------------- 1 | const movies = [ 2 | { 3 | id: 1, 4 | title: 'Movie 43', 5 | year: '2013', 6 | rated: 'R', 7 | released: '25 Jan 2013', 8 | runtime: '94 min', 9 | genre: 'Comedy, Horror, Thriller', 10 | director: 11 | 'Elizabeth Banks, Steven Brill, Steve Carr, Rusty Cundieff, James Duffy, Griffin Dunne, Peter Farrelly, Patrik Forsberg, Will Graham, James Gunn, Brett Ratner, Jonathan van Tulleken, Bob Odenkirk', 12 | writer: 13 | "Rocky Russo, Jeremy Sosenko, Ricky Blitt, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Bill O'Malley (story), Will Graham, Jack Kukoda, Rocky Russo, Jeremy Sosenko, Matt Portenoy, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Claes Kjellstrom (story), Jonas Wittenmark (story), Tobias Carlson (story), Will Carlough, Jonathan van Tulleken, Elizabeth Shapiro, Patrik Forsberg, Olle Sarri, Jacob Fleisher, Greg Pritikin, Rocky Russo, Jeremy Sosenko, James Gunn", 14 | actors: 'Dennis Quaid, Greg Kinnear, Common, Charlie Saxton', 15 | plot: 16 | 'A series of interconnected short films follows a washed-up producer as he pitches insane story lines featuring some of the biggest stars in Hollywood.', 17 | language: 'English', 18 | country: 'USA', 19 | awards: '4 wins & 5 nominations.', 20 | poster: 'https://m.media-amazon.com/images/M/MV5BMTg4NzQ3NDM1Nl5BMl5BanBnXkFtZTcwNjEzMjM3OA@@._V1_SX300.jpg', 21 | ratings: [ 22 | { 23 | Source: 'Internet Movie Database', 24 | Value: '4.3/10' 25 | }, 26 | { 27 | Source: 'Rotten Tomatoes', 28 | Value: '5%' 29 | }, 30 | { 31 | Source: 'Metacritic', 32 | Value: '18/100' 33 | } 34 | ], 35 | metascore: '18', 36 | imdbRating: '4.3', 37 | imdbVotes: '92,291', 38 | imdbID: 'tt1333125', 39 | type: 'movie', 40 | dvd: '18 Jun 2013', 41 | boxOffice: '$8,700,000', 42 | production: 'Relativity Media', 43 | website: 'http://www.facebook.com/WhatIsMovie43' 44 | }, 45 | { 46 | id: 2, 47 | title: 'Scary Movie', 48 | year: '2000', 49 | rated: 'R', 50 | released: '07 Jul 2000', 51 | runtime: '88 min', 52 | genre: 'Comedy', 53 | director: 'Keenen Ivory Wayans', 54 | writer: 'Shawn Wayans, Marlon Wayans, Buddy Johnson, Phil Beauman, Jason Friedberg, Aaron Seltzer', 55 | actors: 'Carmen Electra, Dave Sheridan, Frank B. Moore, Giacomo Baessato', 56 | plot: 57 | 'A year after disposing of the body of a man they accidentally killed, a group of dumb teenagers are stalked by a bumbling serial killer.', 58 | language: 'English', 59 | country: 'USA', 60 | awards: '7 wins & 5 nominations.', 61 | poster: 62 | 'https://m.media-amazon.com/images/M/MV5BMGEzZjdjMGQtZmYzZC00N2I4LThiY2QtNWY5ZmQ3M2ExZmM4XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg', 63 | ratings: [ 64 | { 65 | Source: 'Internet Movie Database', 66 | Value: '6.2/10' 67 | }, 68 | { 69 | Source: 'Rotten Tomatoes', 70 | Value: '53%' 71 | }, 72 | { 73 | Source: 'Metacritic', 74 | Value: '48/100' 75 | } 76 | ], 77 | metascore: '48', 78 | imdbRating: '6.2', 79 | imdbVotes: '213,907', 80 | imdbID: 'tt0175142', 81 | type: 'movie', 82 | dvd: '12 Dec 2000', 83 | boxOffice: 'N/A', 84 | production: 'Dimension Films', 85 | website: 'http://www.scarymovie.com' 86 | }, 87 | { 88 | id: 3, 89 | title: 'Jackass 3D', 90 | year: '2010', 91 | rated: 'R', 92 | released: '15 Oct 2010', 93 | runtime: '94 min', 94 | genre: 'Documentary, Action, Comedy', 95 | director: 'Jeff Tremaine', 96 | writer: 97 | "Jeff Tremaine (concepts by), Johnny Knoxville (concepts by), Bam Margera (concepts by), Steve-O (concepts by), Chris Pontius (concepts by), Ryan Dunn (concepts by), Jason 'Wee Man' Acuña (concepts by), Preston Lacy (concepts by), Ehren McGhehey (concepts by), Dave England (concepts by), Spike Jonze (concepts by), Loomis Fall (concepts by), Barry Owen Smoler (concepts by), The Dudesons (concepts by), Dave Carnie (concepts by), Mike Kassak (concepts by), Madison Clapp (concepts by), Knate Lee (concepts by), Derek Freda (concepts by), Trip Taylor (concepts by), Sean Cliver (concepts by), Dimitry Elyashkevich (concepts by), J.P. Blackmon (concepts by), Rick Kosick (concepts by), Harrison Stone", 98 | actors: 'Johnny Knoxville, Bam Margera, Ryan Dunn, Steve-O', 99 | plot: 100 | 'Johnny Knoxville and company return for the third installment of their TV show spin-off, where dangerous stunts and explicit public displays rule.', 101 | language: 'English', 102 | country: 'USA', 103 | awards: '1 win & 4 nominations.', 104 | poster: 'https://m.media-amazon.com/images/M/MV5BMjI3NTQ1NTE4OV5BMl5BanBnXkFtZTcwMzEzMzA3NA@@._V1_SX300.jpg', 105 | ratings: [ 106 | { 107 | Source: 'Internet Movie Database', 108 | Value: '7.0/10' 109 | }, 110 | { 111 | Source: 'Rotten Tomatoes', 112 | Value: '65%' 113 | }, 114 | { 115 | Source: 'Metacritic', 116 | Value: '56/100' 117 | } 118 | ], 119 | metascore: '56', 120 | imdbRating: '7.0', 121 | imdbVotes: '53,134', 122 | imdbID: 'tt1116184', 123 | type: 'movie', 124 | dvd: '08 Mar 2011', 125 | boxOffice: '$117,222,007', 126 | production: 'Paramount Pictures/MTV Films', 127 | website: 'http://www.jackassmovie.com/' 128 | }, 129 | { 130 | id: 4, 131 | title: 'Titanic', 132 | year: '1997', 133 | rated: 'PG-13', 134 | released: '19 Dec 1997', 135 | runtime: '194 min', 136 | genre: 'Drama, Romance', 137 | director: 'James Cameron', 138 | writer: 'James Cameron', 139 | actors: 'Leonardo DiCaprio, Kate Winslet, Billy Zane, Kathy Bates', 140 | plot: 141 | 'A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.', 142 | language: 'English, Swedish, Italian', 143 | country: 'USA', 144 | awards: 'Won 11 Oscars. Another 111 wins & 77 nominations.', 145 | poster: 146 | 'https://m.media-amazon.com/images/M/MV5BMDdmZGU3NDQtY2E5My00ZTliLWIzOTUtMTY4ZGI1YjdiNjk3XkEyXkFqcGdeQXVyNTA4NzY1MzY@._V1_SX300.jpg', 147 | ratings: [ 148 | { 149 | Source: 'Internet Movie Database', 150 | Value: '7.8/10' 151 | }, 152 | { 153 | Source: 'Rotten Tomatoes', 154 | Value: '89%' 155 | }, 156 | { 157 | Source: 'Metacritic', 158 | Value: '75/100' 159 | } 160 | ], 161 | metascore: '75', 162 | imdbRating: '7.8', 163 | imdbVotes: '951,902', 164 | imdbID: 'tt0120338', 165 | type: 'movie', 166 | dvd: '10 Sep 2012', 167 | boxOffice: 'N/A', 168 | production: 'Paramount Pictures', 169 | website: 'http://www.titanicmovie.com/' 170 | } 171 | ]; 172 | 173 | const reviews = [ 174 | { 175 | id: 1, 176 | movieId: 1, 177 | comment: 'Absolutely terrible, why would anyone watch this piece of junk?', 178 | reviewer: 'Dan Wheeler', 179 | rating: 3 180 | }, 181 | { 182 | id: 2, 183 | movieId: 1, 184 | comment: 'I like the idea of this movie. It is inspiring.', 185 | reviewer: 'Michael Abramov', 186 | rating: 5 187 | }, 188 | { 189 | id: 3, 190 | movieId: 2, 191 | comment: 'I feel like you need to take some sort of drug before you see this movie.', 192 | reviewer: 'Tom Trombone', 193 | rating: 4 194 | }, 195 | { 196 | id: 4, 197 | movieId: 3, 198 | comment: 'This is awesome.', 199 | reviewer: 'Paul Plots', 200 | rating: 9 201 | } 202 | ]; 203 | 204 | export async function getMovies() { 205 | console.log('fetchmovies'); 206 | return new Promise(res => setTimeout(() => res(movies), 1000)); 207 | } 208 | 209 | export async function getMovie(movieId) { 210 | console.log('fetchmovie', movieId); 211 | const movie = movies.find(movie => movie.id === movieId); 212 | return new Promise(res => setTimeout(() => res(movie), 1000)); 213 | } 214 | 215 | export async function getReviewsByMovieId(movieId) { 216 | const movieReviews = reviews.filter(review => review.movieId === movieId); 217 | return new Promise(res => setTimeout(() => res(movieReviews), 2000)); 218 | } 219 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Container, ThemeProvider } from 'fannypack'; 4 | import * as Loads from 'react-loads'; 5 | 6 | import { movieResource, movieReviewsResource } from './resources'; 7 | import MovieDetails from './MovieDetails'; 8 | import MovieList from './MovieList'; 9 | 10 | const loadsConfig = { 11 | suspense: true 12 | }; 13 | 14 | function getMovieLoaders(movieId) { 15 | return { 16 | movie: movieResource.preload({ variables: [movieId] }), 17 | reviews: movieReviewsResource.preload({ variables: [movieId] }) 18 | }; 19 | } 20 | 21 | function App() { 22 | const [startTransition] = React.useTransition({ timeoutMs: 1000 }); 23 | const [movieLoaders, setMovieLoaders] = React.useState(); 24 | const [currentMovieId, setCurrentMovieId] = React.useState(); 25 | 26 | function handleClickBack() { 27 | setCurrentMovieId(); 28 | setMovieLoaders(); 29 | } 30 | 31 | function handleSelectMovie(movie) { 32 | setCurrentMovieId(movie.id); 33 | 34 | startTransition(() => { 35 | const movieLoaders = getMovieLoaders(movie.id); 36 | setMovieLoaders(movieLoaders); 37 | }); 38 | } 39 | 40 | return ( 41 | 42 | 43 | 44 | loading...}> 45 | {movieLoaders ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | ReactDOM.createRoot(document.getElementById('root')).render(); 58 | -------------------------------------------------------------------------------- /examples/render-as-you-fetch/with-resources/src/resources.js: -------------------------------------------------------------------------------- 1 | import * as Loads from 'react-loads'; 2 | import * as api from './api'; 3 | 4 | export const moviesResource = Loads.createResource({ 5 | context: 'movies', 6 | fn: api.getMovies 7 | }); 8 | 9 | export const movieResource = Loads.createResource({ 10 | context: 'movie', 11 | fn: api.getMovie 12 | }); 13 | 14 | export const movieReviewsResource = Loads.createResource({ 15 | context: 'movieReviews', 16 | fn: api.getReviewsByMovieId 17 | }); 18 | -------------------------------------------------------------------------------- /examples/top-movies/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/top-movies/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loads", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.js", 7 | "dependencies": { 8 | "axios": "0.19.0", 9 | "fannypack": "4.19.23", 10 | "react": "16.8.6", 11 | "react-dom": "16.8.6", 12 | "react-loads": "8.3.6", 13 | "react-router": "5.0.1", 14 | "react-router-dom": "5.0.1", 15 | "react-scripts": "3.0.1", 16 | "store": "2.0.12" 17 | }, 18 | "devDependencies": { 19 | "typescript": "3.3.3" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /examples/top-movies/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/top-movies/src/MovieDetails.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Blockquote, 5 | Button, 6 | Columns, 7 | Column, 8 | Flex, 9 | Heading, 10 | Image, 11 | Label, 12 | LayoutSet, 13 | Rating, 14 | Spinner, 15 | Text 16 | } from 'fannypack'; 17 | import * as Loads from 'react-loads'; 18 | 19 | import * as api from './api'; 20 | 21 | export default function MovieDetails(props) { 22 | const { movieId, onClickBack } = props; 23 | 24 | const getMovieRecord = Loads.useLoads('movie', api.getMovie, { variables: [movieId] }); 25 | const movie = getMovieRecord.response || {}; 26 | 27 | const getReviewsRecord = Loads.useLoads('movieReviews', api.getReviewsByMovieId, { variables: [movieId] }); 28 | const reviews = getReviewsRecord.response || []; 29 | 30 | return ( 31 | 32 | 33 | 36 | {getMovieRecord.isPending && } 37 | {getMovieRecord.isResolved && ( 38 | 39 | 40 | 41 | {movie.title} 42 | 43 | 44 | 45 | {movie.year} | Rated {movie.rated} | {movie.runtime} 46 | 47 | 48 | 49 | 50 | 51 | 52 | {movie.plot} 53 | 54 | 55 | 56 | {movie.genre} 57 | 58 | 59 | 60 | {movie.actors} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Reviews 70 | {getReviewsRecord.isPending && } 71 | {getReviewsRecord.isResolved && ( 72 | 73 | {reviews.length === 0 && No reviews.} 74 | {reviews.length > 0 && 75 | reviews.map(review => ( 76 | 77 |
78 | 79 | {review.comment} 80 |
81 | 82 | {review.reviewer} 83 | 84 |
85 |
86 | ))} 87 |
88 | )} 89 |
90 |
91 | )} 92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /examples/top-movies/src/MovieList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Heading, LayoutSet, Spinner } from 'fannypack'; 3 | import * as Loads from 'react-loads'; 4 | 5 | import * as api from './api'; 6 | import MovieListButton from './MovieListButton'; 7 | 8 | export default function MovieList(props) { 9 | const { onSelectMovie } = props; 10 | 11 | const getMoviesRecord = Loads.useLoads('movies', api.getMovies); 12 | const movies = getMoviesRecord.response || []; 13 | 14 | return ( 15 | 16 | Jake's Top Movies 17 | {getMoviesRecord.isPending && } 18 | {getMoviesRecord.isResolved && ( 19 | 20 | {movies.map(movie => ( 21 | onSelectMovie(movie)} /> 22 | ))} 23 | 24 | )} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /examples/top-movies/src/MovieListButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Card, Text, palette, styled } from 'fannypack'; 3 | 4 | const Button = styled(Card)` 5 | cursor: pointer; 6 | display: flex; 7 | justify-content: space-between; 8 | text-decoration: none; 9 | 10 | &:hover { 11 | background-color: ${palette('white600')}; 12 | } 13 | `; 14 | 15 | export default function MovieListButton(props) { 16 | const { movie, isLoading, ...rest } = props; 17 | return ( 18 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /examples/top-movies/src/api.js: -------------------------------------------------------------------------------- 1 | const movies = [ 2 | { 3 | id: 1, 4 | title: 'Movie 43', 5 | year: '2013', 6 | rated: 'R', 7 | released: '25 Jan 2013', 8 | runtime: '94 min', 9 | genre: 'Comedy, Horror, Thriller', 10 | director: 11 | 'Elizabeth Banks, Steven Brill, Steve Carr, Rusty Cundieff, James Duffy, Griffin Dunne, Peter Farrelly, Patrik Forsberg, Will Graham, James Gunn, Brett Ratner, Jonathan van Tulleken, Bob Odenkirk', 12 | writer: 13 | "Rocky Russo, Jeremy Sosenko, Ricky Blitt, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Bill O'Malley (story), Will Graham, Jack Kukoda, Rocky Russo, Jeremy Sosenko, Matt Portenoy, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Claes Kjellstrom (story), Jonas Wittenmark (story), Tobias Carlson (story), Will Carlough, Jonathan van Tulleken, Elizabeth Shapiro, Patrik Forsberg, Olle Sarri, Jacob Fleisher, Greg Pritikin, Rocky Russo, Jeremy Sosenko, James Gunn", 14 | actors: 'Dennis Quaid, Greg Kinnear, Common, Charlie Saxton', 15 | plot: 16 | 'A series of interconnected short films follows a washed-up producer as he pitches insane story lines featuring some of the biggest stars in Hollywood.', 17 | language: 'English', 18 | country: 'USA', 19 | awards: '4 wins & 5 nominations.', 20 | poster: 'https://m.media-amazon.com/images/M/MV5BMTg4NzQ3NDM1Nl5BMl5BanBnXkFtZTcwNjEzMjM3OA@@._V1_SX300.jpg', 21 | ratings: [ 22 | { 23 | Source: 'Internet Movie Database', 24 | Value: '4.3/10' 25 | }, 26 | { 27 | Source: 'Rotten Tomatoes', 28 | Value: '5%' 29 | }, 30 | { 31 | Source: 'Metacritic', 32 | Value: '18/100' 33 | } 34 | ], 35 | metascore: '18', 36 | imdbRating: '4.3', 37 | imdbVotes: '92,291', 38 | imdbID: 'tt1333125', 39 | type: 'movie', 40 | dvd: '18 Jun 2013', 41 | boxOffice: '$8,700,000', 42 | production: 'Relativity Media', 43 | website: 'http://www.facebook.com/WhatIsMovie43' 44 | }, 45 | { 46 | id: 2, 47 | title: 'Scary Movie', 48 | year: '2000', 49 | rated: 'R', 50 | released: '07 Jul 2000', 51 | runtime: '88 min', 52 | genre: 'Comedy', 53 | director: 'Keenen Ivory Wayans', 54 | writer: 'Shawn Wayans, Marlon Wayans, Buddy Johnson, Phil Beauman, Jason Friedberg, Aaron Seltzer', 55 | actors: 'Carmen Electra, Dave Sheridan, Frank B. Moore, Giacomo Baessato', 56 | plot: 57 | 'A year after disposing of the body of a man they accidentally killed, a group of dumb teenagers are stalked by a bumbling serial killer.', 58 | language: 'English', 59 | country: 'USA', 60 | awards: '7 wins & 5 nominations.', 61 | poster: 62 | 'https://m.media-amazon.com/images/M/MV5BMGEzZjdjMGQtZmYzZC00N2I4LThiY2QtNWY5ZmQ3M2ExZmM4XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg', 63 | ratings: [ 64 | { 65 | Source: 'Internet Movie Database', 66 | Value: '6.2/10' 67 | }, 68 | { 69 | Source: 'Rotten Tomatoes', 70 | Value: '53%' 71 | }, 72 | { 73 | Source: 'Metacritic', 74 | Value: '48/100' 75 | } 76 | ], 77 | metascore: '48', 78 | imdbRating: '6.2', 79 | imdbVotes: '213,907', 80 | imdbID: 'tt0175142', 81 | type: 'movie', 82 | dvd: '12 Dec 2000', 83 | boxOffice: 'N/A', 84 | production: 'Dimension Films', 85 | website: 'http://www.scarymovie.com' 86 | }, 87 | { 88 | id: 3, 89 | title: 'Jackass 3D', 90 | year: '2010', 91 | rated: 'R', 92 | released: '15 Oct 2010', 93 | runtime: '94 min', 94 | genre: 'Documentary, Action, Comedy', 95 | director: 'Jeff Tremaine', 96 | writer: 97 | "Jeff Tremaine (concepts by), Johnny Knoxville (concepts by), Bam Margera (concepts by), Steve-O (concepts by), Chris Pontius (concepts by), Ryan Dunn (concepts by), Jason 'Wee Man' Acuña (concepts by), Preston Lacy (concepts by), Ehren McGhehey (concepts by), Dave England (concepts by), Spike Jonze (concepts by), Loomis Fall (concepts by), Barry Owen Smoler (concepts by), The Dudesons (concepts by), Dave Carnie (concepts by), Mike Kassak (concepts by), Madison Clapp (concepts by), Knate Lee (concepts by), Derek Freda (concepts by), Trip Taylor (concepts by), Sean Cliver (concepts by), Dimitry Elyashkevich (concepts by), J.P. Blackmon (concepts by), Rick Kosick (concepts by), Harrison Stone", 98 | actors: 'Johnny Knoxville, Bam Margera, Ryan Dunn, Steve-O', 99 | plot: 100 | 'Johnny Knoxville and company return for the third installment of their TV show spin-off, where dangerous stunts and explicit public displays rule.', 101 | language: 'English', 102 | country: 'USA', 103 | awards: '1 win & 4 nominations.', 104 | poster: 'https://m.media-amazon.com/images/M/MV5BMjI3NTQ1NTE4OV5BMl5BanBnXkFtZTcwMzEzMzA3NA@@._V1_SX300.jpg', 105 | ratings: [ 106 | { 107 | Source: 'Internet Movie Database', 108 | Value: '7.0/10' 109 | }, 110 | { 111 | Source: 'Rotten Tomatoes', 112 | Value: '65%' 113 | }, 114 | { 115 | Source: 'Metacritic', 116 | Value: '56/100' 117 | } 118 | ], 119 | metascore: '56', 120 | imdbRating: '7.0', 121 | imdbVotes: '53,134', 122 | imdbID: 'tt1116184', 123 | type: 'movie', 124 | dvd: '08 Mar 2011', 125 | boxOffice: '$117,222,007', 126 | production: 'Paramount Pictures/MTV Films', 127 | website: 'http://www.jackassmovie.com/' 128 | }, 129 | { 130 | id: 4, 131 | title: 'Titanic', 132 | year: '1997', 133 | rated: 'PG-13', 134 | released: '19 Dec 1997', 135 | runtime: '194 min', 136 | genre: 'Drama, Romance', 137 | director: 'James Cameron', 138 | writer: 'James Cameron', 139 | actors: 'Leonardo DiCaprio, Kate Winslet, Billy Zane, Kathy Bates', 140 | plot: 141 | 'A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.', 142 | language: 'English, Swedish, Italian', 143 | country: 'USA', 144 | awards: 'Won 11 Oscars. Another 111 wins & 77 nominations.', 145 | poster: 146 | 'https://m.media-amazon.com/images/M/MV5BMDdmZGU3NDQtY2E5My00ZTliLWIzOTUtMTY4ZGI1YjdiNjk3XkEyXkFqcGdeQXVyNTA4NzY1MzY@._V1_SX300.jpg', 147 | ratings: [ 148 | { 149 | Source: 'Internet Movie Database', 150 | Value: '7.8/10' 151 | }, 152 | { 153 | Source: 'Rotten Tomatoes', 154 | Value: '89%' 155 | }, 156 | { 157 | Source: 'Metacritic', 158 | Value: '75/100' 159 | } 160 | ], 161 | metascore: '75', 162 | imdbRating: '7.8', 163 | imdbVotes: '951,902', 164 | imdbID: 'tt0120338', 165 | type: 'movie', 166 | dvd: '10 Sep 2012', 167 | boxOffice: 'N/A', 168 | production: 'Paramount Pictures', 169 | website: 'http://www.titanicmovie.com/' 170 | } 171 | ]; 172 | 173 | const reviews = [ 174 | { 175 | id: 1, 176 | movieId: 1, 177 | comment: 'Absolutely terrible, why would anyone watch this piece of junk?', 178 | reviewer: 'Dan Wheeler', 179 | rating: 3 180 | }, 181 | { 182 | id: 2, 183 | movieId: 1, 184 | comment: 'I like the idea of this movie. It is inspiring.', 185 | reviewer: 'Michael Abramov', 186 | rating: 5 187 | }, 188 | { 189 | id: 3, 190 | movieId: 2, 191 | comment: 'I feel like you need to take some sort of drug before you see this movie.', 192 | reviewer: 'Tom Trombone', 193 | rating: 4 194 | }, 195 | { 196 | id: 4, 197 | movieId: 3, 198 | comment: 'This is awesome.', 199 | reviewer: 'Paul Plots', 200 | rating: 9 201 | } 202 | ]; 203 | 204 | export async function getMovies() { 205 | console.log('fetching movies...'); 206 | return new Promise(res => setTimeout(() => res(movies), 1000)); 207 | } 208 | 209 | export async function getMovie(movieId) { 210 | console.log(`fetching movie ${movieId}...`); 211 | const movie = movies.find(movie => movie.id === movieId); 212 | return new Promise(res => setTimeout(() => res(movie), (movieId * 1000) / 4)); 213 | } 214 | 215 | export async function getReviewsByMovieId(movieId) { 216 | console.log(`fetching movie review ${movieId}...`); 217 | const movieReviews = reviews.filter(review => review.movieId === movieId); 218 | return new Promise(res => setTimeout(() => res(movieReviews), (movieId * 2000) / 4)); 219 | } 220 | -------------------------------------------------------------------------------- /examples/top-movies/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Container, ThemeProvider } from 'fannypack'; 4 | 5 | import MovieDetails from './MovieDetails'; 6 | import MovieList from './MovieList'; 7 | 8 | function App() { 9 | const [currentMovieId, setCurrentMovieId] = React.useState(); 10 | 11 | function handleClickBack() { 12 | setCurrentMovieId(); 13 | } 14 | 15 | function handleSelectMovie(movie) { 16 | setCurrentMovieId(movie.id); 17 | } 18 | 19 | return ( 20 | 21 | 22 | {currentMovieId ? ( 23 | 24 | ) : ( 25 | 26 | )} 27 | 28 | 29 | ); 30 | } 31 | 32 | const rootElement = document.getElementById('root'); 33 | ReactDOM.render(, rootElement); 34 | -------------------------------------------------------------------------------- /examples/with-resources/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/with-resources/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "users-list", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.js", 7 | "dependencies": { 8 | "axios": "0.18.0", 9 | "fannypack": "4.19.22", 10 | "react": "16.8.6", 11 | "react-dom": "16.8.6", 12 | "react-loads": "next", 13 | "react-scripts": "3.0.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test --env=jsdom", 19 | "eject": "react-scripts eject" 20 | }, 21 | "browserslist": [">0.2%", "not dead", "not ie <= 11", "not op_mini all"] 22 | } 23 | -------------------------------------------------------------------------------- /examples/with-resources/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 27 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /examples/with-resources/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import axios from 'axios'; 4 | import { Button, Container, Group, Heading, LayoutSet, List, Input, Spinner, ThemeProvider } from 'fannypack'; 5 | import * as Loads from 'react-loads'; 6 | 7 | async function getUsers() { 8 | const response = await axios.get('https://jsonplaceholder.typicode.com/users'); 9 | return response.data; 10 | } 11 | 12 | function addUser({ name }) { 13 | return async meta => { 14 | const response = await axios.post('https://jsonplaceholder.typicode.com/users', { 15 | data: { name } 16 | }); 17 | const currentUsers = meta.cachedRecord.response; 18 | const newUser = response.data.data; 19 | return [...currentUsers, newUser]; 20 | }; 21 | } 22 | 23 | function deleteUser(user) { 24 | return async meta => { 25 | await axios.delete(`https://jsonplaceholder.typicode.com/users/${user.id}`); 26 | const currentUsers = meta.cachedRecord.response; 27 | const newUsers = currentUsers.filter(_user => _user.id !== user.id); 28 | return newUsers; 29 | }; 30 | } 31 | 32 | export const usersResource = Loads.createResource({ 33 | context: 'users', 34 | fn: getUsers, 35 | add: addUser, 36 | delete: deleteUser 37 | }); 38 | 39 | function App() { 40 | const [name, setName] = React.useState(); 41 | const [deletingUserId, setDeletingUserId] = React.useState(); 42 | 43 | const getUsersRecord = usersResource.useLoads(); 44 | const users = getUsersRecord.response; 45 | 46 | const addUserRecord = usersResource.add.useDeferredLoads(); 47 | 48 | const deleteUserRecord = usersResource.delete.useDeferredLoads(); 49 | 50 | async function handleAddUser() { 51 | await addUserRecord.load({ name }); 52 | setName(''); 53 | } 54 | 55 | async function handleDeleteUser(user) { 56 | setDeletingUserId(user.id); 57 | await deleteUserRecord.load(user); 58 | setDeletingUserId(); 59 | } 60 | 61 | return ( 62 | 63 | 64 | Users 65 | {getUsersRecord.isPending && } 66 | {getUsersRecord.isResolved && ( 67 | 68 | 69 | {users.map(user => ( 70 | 71 | {user.name} 72 | 82 | 83 | ))} 84 | 85 | 86 | setName(e.target.value)} value={name} /> 87 | 90 | 91 | 92 | )} 93 | 94 | 95 | ); 96 | } 97 | 98 | const rootElement = document.getElementById('root'); 99 | ReactDOM.render(, rootElement); 100 | -------------------------------------------------------------------------------- /examples/with-typescript/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/with-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/with-typescript/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /examples/with-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-typescript", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "24.0.13", 7 | "@types/node": "12.0.4", 8 | "@types/react": "16.8.19", 9 | "@types/react-dom": "16.8.4", 10 | "axios": "0.18.0", 11 | "fannypack": "4.19.22", 12 | "react": "16.8.6", 13 | "react-dom": "16.8.6", 14 | "react-loads": "next", 15 | "react-scripts": "3.0.1", 16 | "typescript": "3.5.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": "react-app" 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/with-typescript/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jxom/react-loads/20d6a9e583891377e0d73532702664bfde1a0350/examples/with-typescript/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-typescript/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/with-typescript/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/with-typescript/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import axios from 'axios'; 4 | import { Alert, Container, Heading, List, Spinner, ThemeProvider } from 'fannypack'; 5 | import { useLoads } from 'react-loads'; 6 | 7 | type Users = Array<{ id: string; name: string }>; 8 | 9 | function App() { 10 | const getUsers = React.useCallback(async (): Promise => { 11 | const response = await axios.get('https://jsonplaceholder.typicode.com/users'); 12 | return response.data; 13 | }, []); 14 | const getUsersRecord = useLoads('users', getUsers); 15 | const users = (getUsersRecord.response || []); 16 | 17 | return ( 18 | 19 | 20 | Users 21 | {getUsersRecord.isPending && } 22 | {getUsersRecord.isResolved && ( 23 | 24 | {users.map(user => ( 25 | {user.name} 26 | ))} 27 | 28 | )} 29 | {getUsersRecord.isRejected && Error! {getUsersRecord.error.message}} 30 | 31 | 32 | ); 33 | } 34 | 35 | ReactDOM.render(, document.getElementById('root')); 36 | -------------------------------------------------------------------------------- /examples/with-typescript/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/with-typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/jest.setup.js'] 3 | }; 4 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('@babel/polyfill'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-loads", 3 | "version": "9.2.5", 4 | "description": "A simple React component to handle loading state", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "unpkg": "dist/react-loads.min.js", 8 | "types": "ts/index.d.ts", 9 | "files": [ 10 | "es", 11 | "lib", 12 | "umd", 13 | "ts" 14 | ], 15 | "license": "MIT", 16 | "repository": "jxom/react-loads", 17 | "author": { 18 | "name": "jxom", 19 | "email": "jakemoxey@gmail.com", 20 | "url": "jxom.io" 21 | }, 22 | "scripts": { 23 | "build": "yarn build:lib && yarn build:types", 24 | "build:lib": "rollup -c", 25 | "build:types": "tsc --emitDeclarationOnly", 26 | "clean": "rimraf es/ lib/ dist/ ts/ umd/", 27 | "develop": "rollup -c -w", 28 | "lint": "eslint src/**/*.ts src/**/*.tsx", 29 | "test": "jest src/", 30 | "prepublish": "yarn lint && yarn build", 31 | "storybook": "start-storybook -p 6006", 32 | "build-storybook": "build-storybook -o docs/" 33 | }, 34 | "keywords": [ 35 | "react", 36 | "loads", 37 | "loading", 38 | "state" 39 | ], 40 | "peerDependencies": { 41 | "@types/react": ">=16.8.0", 42 | "react": ">=16.8.0", 43 | "react-dom": ">=16.8.0" 44 | }, 45 | "dependencies": {}, 46 | "devDependencies": { 47 | "@babel/cli": "7.2.3", 48 | "@babel/core": "7.2.2", 49 | "@babel/plugin-proposal-class-properties": "7.3.0", 50 | "@babel/plugin-proposal-object-rest-spread": "7.3.1", 51 | "@babel/plugin-syntax-dynamic-import": "7.2.0", 52 | "@babel/polyfill": "7.2.5", 53 | "@babel/preset-env": "7.3.1", 54 | "@babel/preset-react": "7.0.0", 55 | "@babel/preset-typescript": "7.1.0", 56 | "@medipass/eslint-config-react-medipass": "8.4.1", 57 | "@storybook/react": "5.0.11", 58 | "@testing-library/react": "9.5.0", 59 | "@types/jest": "24.0.6", 60 | "axios": "0.18.0", 61 | "babel-eslint": "10.0.1", 62 | "babel-jest": "24.0.0", 63 | "babel-loader": "8.0.5", 64 | "eslint": "5.16.0", 65 | "eslint-plugin-import": "2.17.2", 66 | "eslint-plugin-typescript": "0.14.0", 67 | "fannypack": "4.21.25", 68 | "fast-deep-equal": "2.0.1", 69 | "jest": "24.0.0", 70 | "jest-dom": "3.0.1", 71 | "react": "16.13.0", 72 | "react-dom": "16.13.0", 73 | "react-hot-loader": "4.6.5", 74 | "react-test-renderer": "16.7.0", 75 | "rimraf": "2.6.3", 76 | "rollup": "1.1.2", 77 | "rollup-plugin-babel": "4.3.2", 78 | "rollup-plugin-commonjs": "9.2.0", 79 | "rollup-plugin-ignore": "1.0.4", 80 | "rollup-plugin-json": "3.1.0", 81 | "rollup-plugin-node-resolve": "4.0.0", 82 | "rollup-plugin-replace": "2.1.0", 83 | "rollup-plugin-terser": "4.0.3", 84 | "store": "2.0.12", 85 | "typescript": "3.2.4", 86 | "typescript-eslint-parser": "22.0.0" 87 | }, 88 | "eslintConfig": { 89 | "extends": [ 90 | "@medipass/react-medipass" 91 | ] 92 | }, 93 | "jest": { 94 | "transform": { 95 | "^.+\\.js$": "/jest.transform.js" 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const babel = require('rollup-plugin-babel'); 2 | const resolve = require('rollup-plugin-node-resolve'); 3 | const replace = require('rollup-plugin-replace'); 4 | const commonjs = require('rollup-plugin-commonjs'); 5 | const json = require('rollup-plugin-json'); 6 | const { terser } = require('rollup-plugin-terser'); 7 | const ignore = require('rollup-plugin-ignore'); 8 | 9 | const pkg = require('./package.json'); 10 | 11 | const extensions = ['.ts', '.tsx', '.js', '.jsx', '.json']; 12 | 13 | const makeExternalPredicate = externalArr => { 14 | if (!externalArr.length) { 15 | return () => false; 16 | } 17 | const pattern = new RegExp(`^(${externalArr.join('|')})($|/)`); 18 | return id => pattern.test(id); 19 | }; 20 | 21 | const getExternal = (umd, pkg) => { 22 | const external = [...Object.keys(pkg.peerDependencies)]; 23 | const allExternal = [...external, ...Object.keys(pkg.dependencies)]; 24 | return makeExternalPredicate(umd ? external : allExternal); 25 | }; 26 | 27 | const commonPlugins = [ 28 | babel({ 29 | extensions, 30 | exclude: ['node_modules/**'] 31 | }), 32 | resolve({ extensions, preferBuiltins: false }) 33 | ]; 34 | 35 | const getPlugins = umd => 36 | umd 37 | ? [ 38 | ...commonPlugins, 39 | commonjs({ 40 | include: /node_modules/, 41 | namedExports: { 42 | './node_modules/react-is/index.js': ['isValidElementType', 'isElement', 'ForwardRef'], 43 | './node_modules/lru_map/lru.js': ['LRUMap'] 44 | } 45 | }), 46 | ignore(['stream']), 47 | terser(), 48 | replace({ 49 | 'process.env.NODE_ENV': JSON.stringify('production') 50 | }), 51 | json() 52 | ] 53 | : commonPlugins; 54 | 55 | const getOutput = (umd, pkg) => 56 | umd 57 | ? { 58 | name: 'ReactLoads', 59 | file: pkg.unpkg, 60 | format: 'umd', 61 | exports: 'named', 62 | globals: { 63 | react: 'React', 64 | 'react-dom': 'ReactDOM' 65 | } 66 | } 67 | : [ 68 | { 69 | file: pkg.main, 70 | format: 'cjs', 71 | exports: 'named' 72 | }, 73 | { 74 | file: pkg.module, 75 | format: 'es' 76 | } 77 | ]; 78 | 79 | const createConfig = ({ umd, pkg, plugins = [], ...config }) => ({ 80 | external: getExternal(umd, pkg), 81 | plugins: [...getPlugins(umd), ...plugins], 82 | output: getOutput(umd, pkg), 83 | ...config 84 | }); 85 | 86 | export default [ 87 | createConfig({ 88 | pkg, 89 | input: 'src/index.ts', 90 | output: [ 91 | { 92 | format: 'es', 93 | dir: 'es' 94 | }, 95 | { 96 | format: 'cjs', 97 | dir: 'lib', 98 | exports: 'named' 99 | } 100 | ] 101 | }), 102 | createConfig({ pkg, input: 'src/index.ts', umd: true }) 103 | ]; 104 | -------------------------------------------------------------------------------- /src/Loads.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { LoadsConfig, LoadFunction } from './types'; 3 | import { useLoads } from './useLoads'; 4 | 5 | export type LoadsProps = LoadsConfig & { 6 | context: string; 7 | children: (record: any) => React.ReactNode; 8 | fn: LoadFunction; 9 | inputs?: Array; 10 | }; 11 | 12 | export const Loads = ({ children, context, fn, inputs, ...config }: LoadsProps) => { 13 | const record = useLoads(context, fn, config); 14 | return children(record); 15 | }; 16 | 17 | Loads.defaultProps = { 18 | inputs: [] 19 | }; 20 | -------------------------------------------------------------------------------- /src/LoadsConfig.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { LOAD_POLICIES, CACHE_STRATEGIES } from './constants'; 3 | import { LoadsConfig } from './types'; 4 | 5 | export const defaultConfig = { 6 | cacheTime: 0, 7 | cacheStrategy: CACHE_STRATEGIES.CONTEXT_AND_VARIABLES, 8 | debounce: 0, 9 | debounceCache: true, 10 | dedupingInterval: 500, 11 | delay: 0, 12 | defer: false, 13 | loadPolicy: LOAD_POLICIES.CACHE_AND_LOAD, 14 | revalidateOnWindowFocus: false, 15 | revalidateTime: 300000, 16 | suspense: false, 17 | throwError: false, 18 | timeout: 5000 19 | }; 20 | 21 | export const Context = React.createContext(defaultConfig); 22 | 23 | export function Provider({ children, config }: { children: React.ReactNode; config: LoadsConfig }) { 24 | const newConfig = React.useMemo(() => Object.assign(defaultConfig, config), [config]); 25 | return {children}; 26 | } 27 | -------------------------------------------------------------------------------- /src/__stories__/api.js: -------------------------------------------------------------------------------- 1 | let movies = [ 2 | { 3 | id: 1, 4 | title: 'Movie 43', 5 | year: '2013', 6 | rated: 'R', 7 | released: '25 Jan 2013', 8 | runtime: '94 min', 9 | genre: 'Comedy, Horror, Thriller', 10 | director: 11 | 'Elizabeth Banks, Steven Brill, Steve Carr, Rusty Cundieff, James Duffy, Griffin Dunne, Peter Farrelly, Patrik Forsberg, Will Graham, James Gunn, Brett Ratner, Jonathan van Tulleken, Bob Odenkirk', 12 | writer: 13 | "Rocky Russo, Jeremy Sosenko, Ricky Blitt, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Bill O'Malley (story), Will Graham, Jack Kukoda, Rocky Russo, Jeremy Sosenko, Matt Portenoy, Rocky Russo (screenplay), Jeremy Sosenko (screenplay), Claes Kjellstrom (story), Jonas Wittenmark (story), Tobias Carlson (story), Will Carlough, Jonathan van Tulleken, Elizabeth Shapiro, Patrik Forsberg, Olle Sarri, Jacob Fleisher, Greg Pritikin, Rocky Russo, Jeremy Sosenko, James Gunn", 14 | actors: 'Dennis Quaid, Greg Kinnear, Common, Charlie Saxton', 15 | plot: 16 | 'A series of interconnected short films follows a washed-up producer as he pitches insane story lines featuring some of the biggest stars in Hollywood.', 17 | language: 'English', 18 | country: 'USA', 19 | awards: '4 wins & 5 nominations.', 20 | poster: 'https://m.media-amazon.com/images/M/MV5BMTg4NzQ3NDM1Nl5BMl5BanBnXkFtZTcwNjEzMjM3OA@@._V1_SX300.jpg', 21 | ratings: [ 22 | { 23 | Source: 'Internet Movie Database', 24 | Value: '4.3/10' 25 | }, 26 | { 27 | Source: 'Rotten Tomatoes', 28 | Value: '5%' 29 | }, 30 | { 31 | Source: 'Metacritic', 32 | Value: '18/100' 33 | } 34 | ], 35 | metascore: '18', 36 | imdbRating: '4.3', 37 | imdbVotes: '92,291', 38 | imdbID: 'tt1333125', 39 | type: 'movie', 40 | dvd: '18 Jun 2013', 41 | boxOffice: '$8,700,000', 42 | production: 'Relativity Media', 43 | website: 'http://www.facebook.com/WhatIsMovie43' 44 | }, 45 | { 46 | id: 2, 47 | title: 'Scary Movie', 48 | year: '2000', 49 | rated: 'R', 50 | released: '07 Jul 2000', 51 | runtime: '88 min', 52 | genre: 'Comedy', 53 | director: 'Keenen Ivory Wayans', 54 | writer: 'Shawn Wayans, Marlon Wayans, Buddy Johnson, Phil Beauman, Jason Friedberg, Aaron Seltzer', 55 | actors: 'Carmen Electra, Dave Sheridan, Frank B. Moore, Giacomo Baessato', 56 | plot: 57 | 'A year after disposing of the body of a man they accidentally killed, a group of dumb teenagers are stalked by a bumbling serial killer.', 58 | language: 'English', 59 | country: 'USA', 60 | awards: '7 wins & 5 nominations.', 61 | poster: 62 | 'https://m.media-amazon.com/images/M/MV5BMGEzZjdjMGQtZmYzZC00N2I4LThiY2QtNWY5ZmQ3M2ExZmM4XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg', 63 | ratings: [ 64 | { 65 | Source: 'Internet Movie Database', 66 | Value: '6.2/10' 67 | }, 68 | { 69 | Source: 'Rotten Tomatoes', 70 | Value: '53%' 71 | }, 72 | { 73 | Source: 'Metacritic', 74 | Value: '48/100' 75 | } 76 | ], 77 | metascore: '48', 78 | imdbRating: '6.2', 79 | imdbVotes: '213,907', 80 | imdbID: 'tt0175142', 81 | type: 'movie', 82 | dvd: '12 Dec 2000', 83 | boxOffice: 'N/A', 84 | production: 'Dimension Films', 85 | website: 'http://www.scarymovie.com' 86 | }, 87 | { 88 | id: 3, 89 | title: 'Jackass 3D', 90 | year: '2010', 91 | rated: 'R', 92 | released: '15 Oct 2010', 93 | runtime: '94 min', 94 | genre: 'Documentary, Action, Comedy', 95 | director: 'Jeff Tremaine', 96 | writer: 97 | "Jeff Tremaine (concepts by), Johnny Knoxville (concepts by), Bam Margera (concepts by), Steve-O (concepts by), Chris Pontius (concepts by), Ryan Dunn (concepts by), Jason 'Wee Man' Acuña (concepts by), Preston Lacy (concepts by), Ehren McGhehey (concepts by), Dave England (concepts by), Spike Jonze (concepts by), Loomis Fall (concepts by), Barry Owen Smoler (concepts by), The Dudesons (concepts by), Dave Carnie (concepts by), Mike Kassak (concepts by), Madison Clapp (concepts by), Knate Lee (concepts by), Derek Freda (concepts by), Trip Taylor (concepts by), Sean Cliver (concepts by), Dimitry Elyashkevich (concepts by), J.P. Blackmon (concepts by), Rick Kosick (concepts by), Harrison Stone", 98 | actors: 'Johnny Knoxville, Bam Margera, Ryan Dunn, Steve-O', 99 | plot: 100 | 'Johnny Knoxville and company return for the third installment of their TV show spin-off, where dangerous stunts and explicit public displays rule.', 101 | language: 'English', 102 | country: 'USA', 103 | awards: '1 win & 4 nominations.', 104 | poster: 'https://m.media-amazon.com/images/M/MV5BMjI3NTQ1NTE4OV5BMl5BanBnXkFtZTcwMzEzMzA3NA@@._V1_SX300.jpg', 105 | ratings: [ 106 | { 107 | Source: 'Internet Movie Database', 108 | Value: '7.0/10' 109 | }, 110 | { 111 | Source: 'Rotten Tomatoes', 112 | Value: '65%' 113 | }, 114 | { 115 | Source: 'Metacritic', 116 | Value: '56/100' 117 | } 118 | ], 119 | metascore: '56', 120 | imdbRating: '7.0', 121 | imdbVotes: '53,134', 122 | imdbID: 'tt1116184', 123 | type: 'movie', 124 | dvd: '08 Mar 2011', 125 | boxOffice: '$117,222,007', 126 | production: 'Paramount Pictures/MTV Films', 127 | website: 'http://www.jackassmovie.com/' 128 | }, 129 | { 130 | id: 4, 131 | title: 'Titanic', 132 | year: '1997', 133 | rated: 'PG-13', 134 | released: '19 Dec 1997', 135 | runtime: '194 min', 136 | genre: 'Drama, Romance', 137 | director: 'James Cameron', 138 | writer: 'James Cameron', 139 | actors: 'Leonardo DiCaprio, Kate Winslet, Billy Zane, Kathy Bates', 140 | plot: 141 | 'A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.', 142 | language: 'English, Swedish, Italian', 143 | country: 'USA', 144 | awards: 'Won 11 Oscars. Another 111 wins & 77 nominations.', 145 | poster: 146 | 'https://m.media-amazon.com/images/M/MV5BMDdmZGU3NDQtY2E5My00ZTliLWIzOTUtMTY4ZGI1YjdiNjk3XkEyXkFqcGdeQXVyNTA4NzY1MzY@._V1_SX300.jpg', 147 | ratings: [ 148 | { 149 | Source: 'Internet Movie Database', 150 | Value: '7.8/10' 151 | }, 152 | { 153 | Source: 'Rotten Tomatoes', 154 | Value: '89%' 155 | }, 156 | { 157 | Source: 'Metacritic', 158 | Value: '75/100' 159 | } 160 | ], 161 | metascore: '75', 162 | imdbRating: '7.8', 163 | imdbVotes: '951,902', 164 | imdbID: 'tt0120338', 165 | type: 'movie', 166 | dvd: '10 Sep 2012', 167 | boxOffice: 'N/A', 168 | production: 'Paramount Pictures', 169 | website: 'http://www.titanicmovie.com/' 170 | } 171 | ]; 172 | 173 | let reviews = [ 174 | { 175 | id: 1, 176 | movieId: 1, 177 | comment: 'Absolutely terrible, why would anyone watch this piece of junk?', 178 | reviewer: 'Dan Wheeler', 179 | rating: 3 180 | }, 181 | { 182 | id: 2, 183 | movieId: 1, 184 | comment: 'I like the idea of this movie. It is inspiring.', 185 | reviewer: 'Michael Abramov', 186 | rating: 5 187 | }, 188 | { 189 | id: 3, 190 | movieId: 2, 191 | comment: 'I feel like you need to take some sort of drug before you see this movie.', 192 | reviewer: 'Tom Trombone', 193 | rating: 4 194 | }, 195 | { 196 | id: 4, 197 | movieId: 3, 198 | comment: 'This is awesome.', 199 | reviewer: 'Paul Plots', 200 | rating: 9 201 | } 202 | ]; 203 | 204 | export async function getMovies() { 205 | return new Promise(res => setTimeout(() => res(movies), 1000)); 206 | } 207 | 208 | export async function getMovie(movieId) { 209 | const movie = movies.find(movie => movie.id === movieId); 210 | return new Promise(res => setTimeout(() => res(movie), (movieId * 1000) / 4)); 211 | } 212 | 213 | export async function getReviewsByMovieId(movieId) { 214 | const movieReviews = reviews.filter(review => review.movieId === movieId); 215 | return new Promise(res => setTimeout(() => res(movieReviews), (movieId * 2000) / 4)); 216 | } 217 | 218 | export async function updateMovie(movieId, body) { 219 | const movie = movies.find(movie => movie.id === movieId); 220 | const index = movies.indexOf(movie); 221 | const updatedMovie = { ...movie, ...body }; 222 | movies[index] = updatedMovie; 223 | return new Promise(res => setTimeout(() => res(updatedMovie), (movieId * 1000) / 4)); 224 | } 225 | -------------------------------------------------------------------------------- /src/__stories__/index.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import axios from 'axios'; 3 | import { Alert, Box, Button, Group, Heading, Image, Input, Set, Spinner } from 'fannypack'; 4 | 5 | import { storiesOf } from '@storybook/react'; 6 | 7 | import { cache, useCache, useGetStates, useLoads, useDeferredLoads } from '../index'; 8 | import * as api from './api'; 9 | 10 | storiesOf('useLoads', module) 11 | .add('basic', () => { 12 | function Component() { 13 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 14 | const randomDogRecord = useLoads('basic', getRandomDog); 15 | 16 | return ( 17 | 18 | {randomDogRecord.isPending && } 19 | {randomDogRecord.isResolved && ( 20 | 21 | 22 | Dog 23 | 24 | 27 | 28 | )} 29 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 30 | 31 | ); 32 | } 33 | return ; 34 | }) 35 | .add('basic (deferred)', () => { 36 | function Component() { 37 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 38 | const randomDogRecord = useDeferredLoads('basic', getRandomDog); 39 | 40 | return ( 41 | 42 | {randomDogRecord.isIdle && } 43 | {randomDogRecord.isPending && } 44 | {randomDogRecord.isResolved && ( 45 | 46 | 47 | Dog 48 | 49 | 52 | 53 | )} 54 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 55 | 56 | ); 57 | } 58 | 59 | return ; 60 | }) 61 | .add('with function variables', () => { 62 | function Component() { 63 | const [breed, setBreed] = React.useState('beagle'); 64 | 65 | const getRandomDogByBreed = React.useCallback( 66 | breed => axios.get(`https://dog.ceo/api/breed/${breed}/images/random`), 67 | [] 68 | ); 69 | const randomDogRecord = useLoads('functionVariables', getRandomDogByBreed, { 70 | variables: [breed] 71 | }); 72 | 73 | return ( 74 | 75 | {randomDogRecord.isPending && } 76 | {randomDogRecord.isResolved && ( 77 | 78 | 79 | Dog 80 | 81 | 84 | 85 | 86 | 87 | 88 | )} 89 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 90 | 91 | ); 92 | } 93 | return ; 94 | }) 95 | .add('with function variables (deferred)', () => { 96 | function Component() { 97 | const getRandomDogByBreed = React.useCallback( 98 | breed => axios.get(`https://dog.ceo/api/breed/${breed}/images/random`), 99 | [] 100 | ); 101 | const randomDogRecord = useDeferredLoads('functionVariables', getRandomDogByBreed); 102 | 103 | return ( 104 | 105 | {randomDogRecord.isIdle && } 106 | {randomDogRecord.isPending && } 107 | {randomDogRecord.isResolved && ( 108 | 109 | 110 | Dog 111 | 112 | 115 | 116 | )} 117 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 118 | 119 | ); 120 | } 121 | return ; 122 | }) 123 | .add('suspense', () => { 124 | function Component() { 125 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 126 | const randomDogRecord = useLoads('suspense', getRandomDog, { suspense: true }); 127 | return ( 128 | 129 | {randomDogRecord.isResolved && ( 130 | 131 | 132 | Dog 133 | 134 | 137 | 138 | )} 139 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 140 | 141 | ); 142 | } 143 | return ( 144 | }> 145 | 146 | 147 | ); 148 | }) 149 | .add('custom delay', () => { 150 | function Component() { 151 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 152 | const randomDogRecord = useDeferredLoads(getRandomDog, { 153 | delay: 1000 154 | }); 155 | return ( 156 | 157 | {randomDogRecord.isIdle && } 158 | {randomDogRecord.isPending && } 159 | {randomDogRecord.isResolved && ( 160 | 161 | 162 | Dog 163 | 164 | 167 | 168 | )} 169 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 170 | 171 | ); 172 | } 173 | return ; 174 | }) 175 | .add('no delay', () => { 176 | function Component() { 177 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 178 | const randomDogRecord = useDeferredLoads(getRandomDog, { 179 | delay: 0 180 | }); 181 | return ( 182 | 183 | {randomDogRecord.isIdle && } 184 | {randomDogRecord.isPending && } 185 | {randomDogRecord.isResolved && ( 186 | 187 | 188 | Dog 189 | 190 | 193 | 194 | )} 195 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 196 | 197 | ); 198 | } 199 | return ; 200 | }) 201 | .add('with error', () => { 202 | function Component() { 203 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/sssss/random'), []); 204 | const randomDogRecord = useDeferredLoads(getRandomDog, { 205 | delay: 1000 206 | }); 207 | return ( 208 | 209 | {randomDogRecord.isIdle && } 210 | {randomDogRecord.isPending && } 211 | {randomDogRecord.isResolved && ( 212 | 213 | 214 | Dog 215 | 216 | 219 | 220 | )} 221 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 222 | 223 | ); 224 | } 225 | return ; 226 | }) 227 | .add('with timeout', () => { 228 | function Component() { 229 | const fn = React.useCallback(() => new Promise(res => setTimeout(() => res('this is data'), 2000)), []); 230 | const randomDogRecord = useDeferredLoads(fn, { 231 | timeout: 1000 232 | }); 233 | return ( 234 | 235 | {randomDogRecord.isIdle && } 236 | {(randomDogRecord.isPending || randomDogRecord.isPendingSlow) && } 237 | {randomDogRecord.isPendingSlow && 'taking a while'} 238 | {randomDogRecord.isResolved && {randomDogRecord.response}} 239 | 240 | ); 241 | } 242 | return ; 243 | }) 244 | .add('with dependant useLoads', () => { 245 | function Component({ movieId }) { 246 | const getMovie = React.useCallback(async movieId => { 247 | const movie = await api.getMovie(movieId); 248 | return movie; 249 | }, []); 250 | const movieRecord = useLoads('movie', getMovie, { variables: [movieId] }); 251 | const movie = movieRecord.response; 252 | 253 | const getReviews = React.useCallback(async movieId => { 254 | const reviews = await api.getReviewsByMovieId(movieId); 255 | return reviews; 256 | }, []); 257 | const reviewsRecord = useLoads('reviews', getReviews, { variables: () => [movie.id] }); 258 | const reviews = reviewsRecord.response; 259 | 260 | return ( 261 | 262 | {movieRecord.isPending && } 263 | {movieRecord.isResolved && ( 264 | 265 | Title: {movie.title} 266 | {reviewsRecord.isResolved && reviews.map(review => review.comment)} 267 | 268 | )} 269 | 270 | ); 271 | } 272 | return ; 273 | }) 274 | .add('with dependant useLoads (deferred)', () => { 275 | function Component() { 276 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 277 | const randomDogRecord = useLoads('dependant', getRandomDog, { defer: true }); 278 | 279 | const saveDog = React.useCallback(async imageSrc => new Promise(res => res(`Saved. Image: ${imageSrc}`)), []); 280 | const saveDogRecord = useDeferredLoads(saveDog, { 281 | variables: () => [randomDogRecord.response.data.message] 282 | }); 283 | 284 | return ( 285 | 286 | {randomDogRecord.isIdle && } 287 | {randomDogRecord.isPending && } 288 | {randomDogRecord.isResolved && ( 289 | 290 | 291 | {randomDogRecord.response && ( 292 | Dog 293 | )} 294 | 295 | 296 | 299 | {saveDogRecord.isIdle && ( 300 | 303 | )} 304 | 305 | {saveDogRecord.isResolved && {saveDogRecord.response}} 306 | 307 | )} 308 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 309 | 310 | ); 311 | } 312 | return ; 313 | }) 314 | .add('with inputs', () => { 315 | function Component() { 316 | const [breed, setBreed] = useState('dingo'); 317 | 318 | const getRandomDogByBreed = React.useCallback( 319 | () => axios.get(`https://dog.ceo/api/breed/${breed}/images/random`), 320 | [breed] 321 | ); 322 | const randomDogRecord = useLoads('inputs', getRandomDogByBreed); 323 | 324 | return ( 325 | 326 | {randomDogRecord.isPending && } 327 | {randomDogRecord.isResolved && ( 328 | 329 | 330 | Dog 331 | 332 | 333 | 339 | 342 | 343 | 344 | )} 345 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 346 | 347 | ); 348 | } 349 | return ; 350 | }) 351 | .add('with update fn', () => { 352 | function Component() { 353 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 354 | const getRandomDoberman = React.useCallback( 355 | () => axios.get('https://dog.ceo/api/breed/doberman/images/random'), 356 | [] 357 | ); 358 | const randomDogRecord = useLoads('updateFn', getRandomDog, { 359 | update: getRandomDoberman 360 | }); 361 | return ( 362 | 363 | {randomDogRecord.isPending && } 364 | {randomDogRecord.isResolved && ( 365 | 366 | 367 | Dog 368 | 369 | 370 | 371 | 372 | )} 373 | 374 | ); 375 | } 376 | return ; 377 | }) 378 | .add('with update fns', () => { 379 | function Component() { 380 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 381 | const getRandomDoberman = React.useCallback( 382 | () => axios.get('https://dog.ceo/api/breed/doberman/images/random'), 383 | [] 384 | ); 385 | const getRandomPoodle = React.useCallback(() => axios.get('https://dog.ceo/api/breed/poodle/images/random'), []); 386 | 387 | const randomDogRecord = useLoads('updateFns', getRandomDog, { 388 | update: [getRandomDoberman, getRandomPoodle] 389 | }); 390 | const [loadDoberman, loadPoodle] = randomDogRecord.update; 391 | 392 | return ( 393 | 394 | {randomDogRecord.isPending && } 395 | {randomDogRecord.isResolved && ( 396 | 397 | 398 | Dog 399 | 400 | 401 | 402 | 403 | 404 | )} 405 | 406 | ); 407 | } 408 | return ; 409 | }) 410 | .add('with optimistic updates', () => { 411 | function Component({ movieId }) { 412 | const [ratingValue, setRatingValue] = React.useState(); 413 | 414 | const getMovie = React.useCallback(() => api.getMovie(movieId), [movieId]); 415 | const movieRecord = useLoads('movie', getMovie); 416 | const movie = movieRecord.response || {}; 417 | 418 | const updateMovie = React.useCallback( 419 | () => meta => { 420 | meta.setResponse(movie => ({ ...movie, imdbRating: ratingValue })); 421 | return api.updateMovie(movieId, { imdbRating: ratingValue }); 422 | }, 423 | [movieId, ratingValue] 424 | ); 425 | const updateMovieRecord = useDeferredLoads('movie', updateMovie); 426 | 427 | return ( 428 | 429 | {movieRecord.isPending && } 430 | {movieRecord.isResolved && ( 431 | 432 | 433 | {movie.title} 434 | 435 | Rating: {movie.imdbRating} 436 | 437 | setRatingValue(e.target.value)} 439 | placeholder="Enter new rating" 440 | value={ratingValue} 441 | /> 442 | 445 | 446 | 447 | )} 448 | {movieRecord.isRejected && {movieRecord.error.message}} 449 | 450 | ); 451 | } 452 | return ; 453 | }) 454 | .add('with onResolve', () => { 455 | function Component() { 456 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 457 | const onResolve = React.useCallback(record => console.log('success', record), []); 458 | const randomDogRecord = useLoads('basic', getRandomDog, { onResolve }); 459 | 460 | return ( 461 | 462 | {randomDogRecord.isPending && } 463 | {randomDogRecord.isResolved && ( 464 | 465 | 466 | Dog 467 | 468 | 471 | 472 | )} 473 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 474 | 475 | ); 476 | } 477 | return ; 478 | }) 479 | .add('with onReject', () => { 480 | function Component() { 481 | const getSomething = React.useCallback(async () => { 482 | return new Promise((res, rej) => setTimeout(() => rej(new Error('This is an error.')), 1000)); 483 | }, []); 484 | const onReject = React.useCallback(error => console.log('error', error), []); 485 | const somethingRecord = useLoads('rejectHook', getSomething, { onReject }); 486 | 487 | return ( 488 | 489 | {somethingRecord.isPending && } 490 | {somethingRecord.isRejected && {somethingRecord.error.message}} 491 | 492 | ); 493 | } 494 | return ; 495 | }) 496 | .add('with revalidateTime', () => { 497 | function Component() { 498 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 499 | const randomDogRecord = useLoads('revalidateTime', getRandomDog, { 500 | revalidateTime: 5000, 501 | loadPolicy: 'cache-first' 502 | }); 503 | 504 | return ( 505 | 506 | {randomDogRecord.isPending && } 507 | {randomDogRecord.isResolved && ( 508 | 509 | 510 | Dog 511 | 512 | 515 | 516 | )} 517 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 518 | 519 | ); 520 | } 521 | return ; 522 | }) 523 | .add('with cacheTime', () => { 524 | function Component() { 525 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 526 | const randomDogRecord = useLoads('cacheTime', getRandomDog, { 527 | cacheTime: 5000 528 | }); 529 | 530 | return ( 531 | 532 | {randomDogRecord.isPending && } 533 | {randomDogRecord.isResolved && ( 534 | 535 | 536 | Dog 537 | 538 | 541 | 542 | )} 543 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 544 | 545 | ); 546 | } 547 | return ; 548 | }) 549 | .add('with dedupingInterval', () => { 550 | function Component() { 551 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 552 | const randomDogRecord = useLoads('dedupingInterval', getRandomDog, { 553 | dedupingInterval: 2000 554 | }); 555 | 556 | return ( 557 | 558 | {randomDogRecord.isPending && } 559 | {randomDogRecord.isResolved && ( 560 | 561 | 562 | Dog 563 | 564 | 567 | 568 | )} 569 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 570 | 571 | ); 572 | } 573 | return ; 574 | }) 575 | .add('with debounce', () => { 576 | function Component() { 577 | const [value, setValue] = React.useState('poodle'); 578 | 579 | const getRandomDog = React.useCallback( 580 | ({ value }) => axios.get(`https://dog.ceo/api/breed/${value}/images/random`), 581 | [] 582 | ); 583 | const randomDogRecord = useLoads('debounce', getRandomDog, { 584 | debounce: 1000, 585 | variables: [{ value }] 586 | }); 587 | 588 | return ( 589 | 590 | setValue(e.target.value)} value={value} /> 591 | {randomDogRecord.isPending && } 592 | {randomDogRecord.isResolved && 593 | randomDogRecord.response && ( 594 | 595 | 596 | Dog 597 | 598 | 601 | 602 | )} 603 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 604 | 605 | ); 606 | } 607 | return ; 608 | }) 609 | .add('with pollingInterval', () => { 610 | function Component() { 611 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 612 | const randomDogRecord = useLoads('pollingInterval', getRandomDog, { 613 | pollingInterval: 2000 614 | }); 615 | 616 | return ( 617 | 618 | {randomDogRecord.isPending && } 619 | {randomDogRecord.isResolved && ( 620 | 621 | 622 | Dog 623 | 624 | 627 | 628 | )} 629 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 630 | 631 | ); 632 | } 633 | return ; 634 | }) 635 | .add('with rejectRetryInterval', () => { 636 | function Component() { 637 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/sssss/random'), []); 638 | const randomDogRecord = useDeferredLoads(getRandomDog, { 639 | rejectRetryInterval: 5000 640 | }); 641 | return ( 642 | 643 | {randomDogRecord.isIdle && } 644 | {randomDogRecord.isPending && } 645 | {randomDogRecord.isResolved && ( 646 | 647 | 648 | Dog 649 | 650 | 653 | 654 | )} 655 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 656 | 657 | ); 658 | } 659 | return ; 660 | }) 661 | .add('with rejectRetryInterval (function)', () => { 662 | function Component() { 663 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/sssss/random'), []); 664 | const randomDogRecord = useDeferredLoads(getRandomDog, { 665 | rejectRetryInterval: count => count * 5000 666 | }); 667 | return ( 668 | 669 | {randomDogRecord.isIdle && } 670 | {randomDogRecord.isPending && } 671 | {randomDogRecord.isResolved && ( 672 | 673 | 674 | Dog 675 | 676 | 679 | 680 | )} 681 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 682 | 683 | ); 684 | } 685 | return ; 686 | }) 687 | .add('with load-only loadPolicy', () => { 688 | function Component() { 689 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 690 | const randomDogRecord = useLoads('loadOnly', getRandomDog, { loadPolicy: 'load-only' }); 691 | 692 | return ( 693 | 694 | {randomDogRecord.isPending && } 695 | {randomDogRecord.isResolved && ( 696 | 697 | 698 | Dog 699 | 700 | 703 | 704 | )} 705 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 706 | 707 | ); 708 | } 709 | return ; 710 | }) 711 | .add('with cache-and-load loadPolicy', () => { 712 | function Component() { 713 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 714 | const randomDogRecord = useLoads('cacheAndLoad', getRandomDog, { loadPolicy: 'cache-and-load' }); 715 | 716 | return ( 717 | 718 | {randomDogRecord.isPending && } 719 | {randomDogRecord.isResolved && ( 720 | 721 | 722 | Dog 723 | 724 | 727 | 728 | )} 729 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 730 | 731 | ); 732 | } 733 | return ; 734 | }) 735 | .add('with cache-first loadPolicy', () => { 736 | function Component() { 737 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 738 | const randomDogRecord = useLoads('cacheFirst', getRandomDog, { loadPolicy: 'cache-first' }); 739 | 740 | return ( 741 | 742 | {randomDogRecord.isPending && } 743 | {randomDogRecord.isResolved && ( 744 | 745 | 746 | Dog 747 | 748 | 751 | 752 | )} 753 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 754 | 755 | ); 756 | } 757 | return ; 758 | }) 759 | .add('with key-only cacheStrategy', () => { 760 | function Component() { 761 | const [breed, setBreed] = React.useState('beagle'); 762 | 763 | const getRandomDogByBreed = React.useCallback( 764 | breed => axios.get(`https://dog.ceo/api/breed/${breed}/images/random`), 765 | [] 766 | ); 767 | const randomDogRecord = useLoads('functionVariables', getRandomDogByBreed, { 768 | cacheStrategy: 'key-only', 769 | variables: [breed] 770 | }); 771 | 772 | return ( 773 | 774 | {randomDogRecord.isPending && } 775 | {randomDogRecord.isResolved && ( 776 | 777 | 778 | Dog 779 | 780 | 783 | 784 | 785 | 786 | 787 | )} 788 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 789 | 790 | ); 791 | } 792 | return ; 793 | }) 794 | .add('with initialResponse', () => { 795 | function Component() { 796 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 797 | const randomDogRecord = useLoads('basic', getRandomDog, { 798 | initialResponse: { data: { message: 'https://images.dog.ceo/breeds/schnauzer-miniature/n02097047_2002.jpg' } } 799 | }); 800 | 801 | return ( 802 | 803 | {randomDogRecord.isPending && } 804 | {randomDogRecord.isResolved && ( 805 | 806 | 807 | Dog 808 | 809 | 812 | 813 | )} 814 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 815 | 816 | ); 817 | } 818 | return ; 819 | }); 820 | 821 | storiesOf('useCache', module).add('cache', () => { 822 | function Component() { 823 | const getRandomDog = React.useCallback(() => axios.get('https://dog.ceo/api/breeds/image/random'), []); 824 | const randomDogRecord = useDeferredLoads('basic', getRandomDog); 825 | 826 | return ( 827 | 828 | {randomDogRecord.isIdle && } 829 | {randomDogRecord.isPending && } 830 | {randomDogRecord.isResolved && ( 831 | 832 | 833 | Dog 834 | 835 | 838 | 839 | )} 840 | {randomDogRecord.isRejected && {randomDogRecord.error.message}} 841 | 842 | ); 843 | } 844 | 845 | function Cache() { 846 | const randomDogRecord = useCache('basic'); 847 | if (randomDogRecord && randomDogRecord.response) { 848 | return ( 849 | 850 | Cached: 851 | Dog 852 | 853 | ); 854 | } 855 | return null; 856 | } 857 | 858 | return ( 859 | 860 | 861 | 862 | 863 | ); 864 | }); 865 | 866 | storiesOf('useGetStates', module).add('basic', () => { 867 | function Component() { 868 | const getSomething = React.useCallback(async () => { 869 | return new Promise(res => setTimeout(() => res('This is something.'), 1000)); 870 | }, []); 871 | const somethingRecord = useLoads('something', getSomething); 872 | 873 | const getAnother = React.useCallback(async () => { 874 | return new Promise(res => setTimeout(() => res('This is another.'), 5000)); 875 | }, []); 876 | const anotherRecord = useLoads('another', getAnother, { timeout: 3000 }); 877 | 878 | const states = useGetStates(somethingRecord, anotherRecord); 879 | 880 | return ( 881 | 882 | {states.isPending && 'Pending...'} 883 | {states.isPendingSlow && ' Taking a while...'} 884 | {states.isResolved && `Both records have resolved. ${somethingRecord.response} ${anotherRecord.response}`} 885 | 886 | ); 887 | } 888 | return ; 889 | }); 890 | 891 | storiesOf('cache', module).add('cache.clear', () => { 892 | function Component() { 893 | return ( 894 | 895 | 896 | 897 | ); 898 | } 899 | 900 | return ; 901 | }); 902 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/useLoads.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`context (cache) with deferred load initially renders & caches data correctly 1`] = ` 4 |
5 | 8 | 9 | success 10 | 11 | 12 | mockData 13 | 14 |
15 | `; 16 | 17 | exports[`context (cache) with deferred load renders the cached data correctly on subsequent load 1`] = ` 18 |
19 | 22 | 23 | success 24 | 25 | 26 | mockData 27 | 28 |
29 | `; 30 | 31 | exports[`context (cache) with load on mount on success initially renders & caches data correctly 1`] = ` 32 |
33 | 34 | success 35 | 36 | 37 | mockData 38 | 39 |
40 | `; 41 | 42 | exports[`context (cache) with load on mount on success renders the cached data correctly on subsequent load 1`] = ` 43 |
44 | 45 | success 46 | 47 | 48 | mockData 49 | 50 |
51 | `; 52 | 53 | exports[`states renders idle correctly the loading function has not been invoked 1`] = ` 54 |
55 | idle 56 |
57 | `; 58 | 59 | exports[`states with deferred load renders error correctly when the function rejects after 500ms 1`] = ` 60 |
61 | 64 | idle 65 |
66 | `; 67 | 68 | exports[`states with deferred load renders error correctly when the function rejects after 500ms 2`] = ` 69 |
70 | 73 | error 74 |
75 | `; 76 | 77 | exports[`states with deferred load renders loading indicator correctly (default delay) 1`] = ` 78 |
79 | 82 | loading 83 |
84 | `; 85 | 86 | exports[`states with deferred load renders success correctly when the function has been resolved 1`] = ` 87 |
88 | 91 | idle 92 |
93 | `; 94 | 95 | exports[`states with deferred load renders success correctly when the function has been resolved 2`] = ` 96 |
97 | 100 | success 101 |
102 | `; 103 | 104 | exports[`states with deferred load renders success correctly when the function resolves after 500ms 1`] = ` 105 |
106 | 109 | idle 110 |
111 | `; 112 | 113 | exports[`states with deferred load renders success correctly when the function resolves after 500ms 2`] = ` 114 |
115 | 118 | success 119 |
120 | `; 121 | 122 | exports[`states with deferred load renders timeout indicator correctly 1`] = ` 123 |
124 | 127 |
128 | loading 129 |
130 |
131 | timeout 132 |
133 |
134 | `; 135 | 136 | exports[`states with load on initial render renders error correctly when the function rejects after 500ms 1`] = ` 137 |
138 | error 139 |
140 | `; 141 | 142 | exports[`states with load on initial render renders loading indicator correctly (default delay) 1`] = ` 143 |
144 | loading 145 |
146 | `; 147 | 148 | exports[`states with load on initial render renders success correctly when the function has been resolved 1`] = ` 149 |
150 | success 151 |
152 | `; 153 | 154 | exports[`states with load on initial render renders success correctly when the function resolves after 500ms 1`] = ` 155 |
156 | success 157 |
158 | `; 159 | 160 | exports[`states with load on initial render renders timeout indicator correctly 1`] = ` 161 |
162 |
163 | loading 164 |
165 |
166 | timeout 167 |
168 |
169 | `; 170 | -------------------------------------------------------------------------------- /src/__tests__/useLoads.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cleanup, render, waitForElement, wait, fireEvent } from '@testing-library/react'; 3 | import { cache, useLoads, useDeferredLoads } from '../index'; 4 | 5 | afterEach(() => cleanup()); 6 | 7 | describe('states', () => { 8 | beforeEach(() => cache.records.clear()); 9 | 10 | it('renders idle correctly the loading function has not been invoked', () => { 11 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 500))); 12 | const Component = () => { 13 | const testLoader = useDeferredLoads(fn); 14 | return {testLoader.isIdle && 'idle'}; 15 | }; 16 | 17 | const { container } = render(); 18 | 19 | expect(fn).toBeCalledTimes(0); 20 | expect(container).toMatchSnapshot(); 21 | }); 22 | 23 | describe('with load on initial render', () => { 24 | it('renders loading indicator correctly (default delay)', async () => { 25 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 500))); 26 | const Component = () => { 27 | const testLoader = useLoads('test', fn); 28 | return {testLoader.isPending && 'loading'}; 29 | }; 30 | 31 | const { container, getByText } = render(); 32 | 33 | await waitForElement(() => getByText('loading')); 34 | expect(fn).toBeCalledTimes(1); 35 | expect(container).toMatchSnapshot(); 36 | }); 37 | 38 | it('renders timeout indicator correctly', async () => { 39 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 1000))); 40 | const Component = () => { 41 | const testLoader = useLoads('test', fn, { timeout: 400 }); 42 | return ( 43 | 44 | {testLoader.isPending &&
loading
} 45 | {testLoader.isPendingSlow &&
timeout
} 46 |
47 | ); 48 | }; 49 | 50 | const { container, getByText } = render(); 51 | 52 | await waitForElement(() => getByText('loading')); 53 | await waitForElement(() => getByText('timeout')); 54 | expect(fn).toBeCalledTimes(1); 55 | expect(container).toMatchSnapshot(); 56 | }); 57 | 58 | it('renders success correctly when the function has been resolved', async () => { 59 | const fn = jest.fn().mockReturnValue(new Promise(res => res())); 60 | const Component = () => { 61 | const testLoader = useLoads('test', fn); 62 | return ( 63 | 64 | {testLoader.isIdle && 'idle'} 65 | {testLoader.isPending && 'loading'} 66 | {testLoader.isResolved && 'success'} 67 | 68 | ); 69 | }; 70 | 71 | const { container, getByText } = render(); 72 | 73 | await waitForElement(() => getByText('success')); 74 | expect(fn).toBeCalledTimes(1); 75 | expect(container).toMatchSnapshot(); 76 | }); 77 | 78 | it('renders success correctly when the function resolves after 500ms', async () => { 79 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 500))); 80 | const Component = () => { 81 | const testLoader = useLoads('test', fn); 82 | return ( 83 | 84 | {testLoader.isIdle && 'idle'} 85 | {testLoader.isPending && 'loading'} 86 | {testLoader.isResolved && 'success'} 87 | 88 | ); 89 | }; 90 | 91 | const { container, getByText } = render(); 92 | 93 | await waitForElement(() => getByText('loading')); 94 | await waitForElement(() => getByText('success')); 95 | expect(fn).toBeCalledTimes(1); 96 | expect(container).toMatchSnapshot(); 97 | }); 98 | 99 | it('renders error correctly when the function rejects after 500ms', async () => { 100 | const fn = jest.fn().mockReturnValue(new Promise((res, rej) => setTimeout(rej, 500))); 101 | const Component = () => { 102 | const testLoader = useLoads('test', fn); 103 | return ( 104 | 105 | {testLoader.isIdle && 'idle'} 106 | {testLoader.isPending && 'loading'} 107 | {testLoader.isRejected && 'error'} 108 | 109 | ); 110 | }; 111 | 112 | const { container, getByText } = render(); 113 | 114 | await waitForElement(() => getByText('loading')); 115 | await waitForElement(() => getByText('error')); 116 | expect(fn).toBeCalledTimes(1); 117 | expect(container).toMatchSnapshot(); 118 | }); 119 | }); 120 | 121 | describe('with deferred load', () => { 122 | it('renders loading indicator correctly (default delay)', async () => { 123 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 500))); 124 | const Component = () => { 125 | const testLoader = useDeferredLoads(fn); 126 | return ( 127 | 128 | 129 | {testLoader.isPending && 'loading'} 130 | 131 | ); 132 | }; 133 | 134 | const { container, getByText } = render(); 135 | 136 | expect(fn).toBeCalledTimes(0); 137 | fireEvent.click(getByText('load')); 138 | expect(fn).toBeCalledTimes(1); 139 | await waitForElement(() => getByText('loading')); 140 | expect(container).toMatchSnapshot(); 141 | }); 142 | 143 | it('renders timeout indicator correctly', async () => { 144 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 1000))); 145 | const Component = () => { 146 | const testLoader = useDeferredLoads(fn, { timeout: 400 }); 147 | return ( 148 | 149 | 150 | {testLoader.isPending &&
loading
} 151 | {testLoader.isPendingSlow &&
timeout
} 152 |
153 | ); 154 | }; 155 | 156 | const { container, getByText } = render(); 157 | 158 | expect(fn).toBeCalledTimes(0); 159 | fireEvent.click(getByText('load')); 160 | expect(fn).toBeCalledTimes(1); 161 | await waitForElement(() => getByText('loading')); 162 | await waitForElement(() => getByText('timeout')); 163 | expect(container).toMatchSnapshot(); 164 | }); 165 | 166 | it('renders success correctly when the function has been resolved', async () => { 167 | const fn = jest.fn().mockReturnValue(new Promise(res => res())); 168 | const Component = () => { 169 | const testLoader = useDeferredLoads(fn); 170 | return ( 171 | 172 | 173 | {testLoader.isIdle && 'idle'} 174 | {testLoader.isPending && 'loading'} 175 | {testLoader.isResolved && 'success'} 176 | 177 | ); 178 | }; 179 | 180 | const { container, getByText } = render(); 181 | 182 | expect(fn).toBeCalledTimes(0); 183 | expect(container).toMatchSnapshot(); 184 | fireEvent.click(getByText('load')); 185 | expect(fn).toBeCalledTimes(1); 186 | await waitForElement(() => getByText('success')); 187 | expect(container).toMatchSnapshot(); 188 | }); 189 | 190 | it('renders success correctly when the function resolves after 500ms', async () => { 191 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(res, 500))); 192 | const Component = () => { 193 | const testLoader = useDeferredLoads(fn); 194 | return ( 195 | 196 | 197 | {testLoader.isIdle && 'idle'} 198 | {testLoader.isPending && 'loading'} 199 | {testLoader.isResolved && 'success'} 200 | 201 | ); 202 | }; 203 | 204 | const { container, getByText } = render(); 205 | 206 | expect(fn).toBeCalledTimes(0); 207 | expect(container).toMatchSnapshot(); 208 | fireEvent.click(getByText('load')); 209 | await waitForElement(() => getByText('loading')); 210 | await waitForElement(() => getByText('success')); 211 | expect(fn).toBeCalledTimes(1); 212 | expect(container).toMatchSnapshot(); 213 | }); 214 | 215 | it('renders error correctly when the function rejects after 500ms', async () => { 216 | const fn = jest.fn().mockReturnValue(new Promise((res, rej) => setTimeout(rej, 500))); 217 | const Component = () => { 218 | const testLoader = useDeferredLoads(fn); 219 | return ( 220 | 221 | 222 | {testLoader.isIdle && 'idle'} 223 | {testLoader.isPending && 'loading'} 224 | {testLoader.isRejected && 'error'} 225 | 226 | ); 227 | }; 228 | 229 | const { container, getByText } = render(); 230 | 231 | expect(fn).toBeCalledTimes(0); 232 | expect(container).toMatchSnapshot(); 233 | fireEvent.click(getByText('load')); 234 | await waitForElement(() => getByText('loading')); 235 | await waitForElement(() => getByText('error')); 236 | expect(fn).toBeCalledTimes(1); 237 | expect(container).toMatchSnapshot(); 238 | }); 239 | }); 240 | }); 241 | 242 | describe('context (cache)', () => { 243 | describe('with load on mount', () => { 244 | describe('on success', () => { 245 | it('initially renders & caches data correctly', async () => { 246 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(() => res('mockData'), 50))); 247 | const Component = () => { 248 | const testLoader = useLoads('success', fn); 249 | return ( 250 | 251 | {testLoader.isPending && loading} 252 | {testLoader.isResolved && success} 253 | {testLoader.response} 254 | 255 | ); 256 | }; 257 | 258 | const { container, getByText } = render(); 259 | 260 | await waitForElement(() => getByText('loading')); 261 | await waitForElement(() => getByText('success')); 262 | expect(fn).toBeCalledTimes(1); 263 | expect(container).toMatchSnapshot(); 264 | }); 265 | 266 | it('renders the cached data correctly on subsequent load', async () => { 267 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(() => res('mockData'), 50))); 268 | const Component = () => { 269 | const testLoader = useLoads('success', fn); 270 | return ( 271 | 272 | {testLoader.isPending && loading} 273 | {testLoader.isResolved && success} 274 | {testLoader.response} 275 | 276 | ); 277 | }; 278 | 279 | const { container, getByText } = render(); 280 | 281 | expect(fn).toBeCalledTimes(0); 282 | await waitForElement(() => getByText('success')); 283 | expect(container).toMatchSnapshot(); 284 | }); 285 | }); 286 | }); 287 | 288 | describe('with deferred load', () => { 289 | it('initially renders & caches data correctly', async () => { 290 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(() => res('mockData'), 50))); 291 | const Component = () => { 292 | const testLoader = useDeferredLoads(fn); 293 | return ( 294 | 295 | 296 | {testLoader.isPending && loading} 297 | {testLoader.isResolved && success} 298 | {testLoader.response} 299 | 300 | ); 301 | }; 302 | 303 | const { container, getByText } = render(); 304 | 305 | fireEvent.click(getByText('load')); 306 | await waitForElement(() => getByText('loading')); 307 | await waitForElement(() => getByText('success')); 308 | expect(fn).toBeCalledTimes(1); 309 | expect(container).toMatchSnapshot(); 310 | }); 311 | 312 | it('renders the cached data correctly on subsequent load', async () => { 313 | const fn = jest.fn().mockReturnValue(new Promise(res => setTimeout(() => res('mockData'), 50))); 314 | const Component = () => { 315 | const testLoader = useDeferredLoads('deferred', fn); 316 | return ( 317 | 318 | 319 | {testLoader.isPending && loading} 320 | {testLoader.isResolved && success} 321 | {testLoader.response} 322 | 323 | ); 324 | }; 325 | 326 | const { container, getByText } = render(); 327 | 328 | fireEvent.click(getByText('load')); 329 | await waitForElement(() => getByText('success')); 330 | expect(container).toMatchSnapshot(); 331 | }); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { LOAD_POLICIES, CACHE_STRATEGIES } from './constants'; 2 | import { CacheProvider, LoadsConfig, Record } from './types'; 3 | 4 | //////////////////////////////////////////////////////// 5 | 6 | export const invocationTimestamps = new Map(); 7 | 8 | //////////////////////////////////////////////////////// 9 | 10 | const recordsCache = new Map(); 11 | export const records = { 12 | clear(opts?: { cacheProvider?: CacheProvider | void }) { 13 | recordsCache.clear(); 14 | 15 | if (opts && opts.cacheProvider) { 16 | opts.cacheProvider.clear(); 17 | } 18 | 19 | return; 20 | }, 21 | delete(key: string, opts?: { cacheProvider?: CacheProvider | void }) { 22 | recordsCache.delete(key); 23 | 24 | if (opts && opts.cacheProvider) { 25 | opts.cacheProvider.delete(key); 26 | } 27 | 28 | return; 29 | }, 30 | set( 31 | key: string, 32 | valOrFn: Record | ((record: Record) => Record), 33 | opts: { cacheTime?: number; cacheProvider?: CacheProvider | void } 34 | ) { 35 | const record = recordsCache.get(key); 36 | if (record && record.cacheTimeout) { 37 | clearTimeout(record.cacheTimeout); 38 | } 39 | 40 | let val = valOrFn; 41 | if (typeof val === 'function') { 42 | val = val(record || {}); 43 | } 44 | 45 | let cacheTimeout; 46 | if (opts && opts.cacheTime) { 47 | cacheTimeout = setTimeout(() => { 48 | records.delete(key); 49 | }, opts.cacheTime); 50 | } 51 | 52 | // Set an updated timestamp on the cached record 53 | val = { ...val, cacheTimeout, updated: new Date() }; 54 | 55 | recordsCache.set(key, val); 56 | if (opts && opts.cacheProvider) { 57 | opts.cacheProvider.set(key, val); 58 | } 59 | 60 | return; 61 | }, 62 | get(key: string, opts?: { cacheProvider?: CacheProvider | void }): Record | undefined { 63 | // First, check to see if the record exists in the cache. 64 | const record = recordsCache.get(key); 65 | if (record) { 66 | return record as Record; 67 | } 68 | 69 | // Otherwise, fallback to the cache provider. 70 | if (opts && opts.cacheProvider) { 71 | const value = opts.cacheProvider.get(key); 72 | if (value) { 73 | return value; 74 | } 75 | } 76 | 77 | return undefined; 78 | } 79 | }; 80 | 81 | //////////////////////////////////////////////////////// 82 | 83 | export const promises = new Map(); 84 | export const revalidators = new Map(); 85 | export const suspenders = new Map(); 86 | export const updaters = new Map(); 87 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { LoadPolicy, LoadingState, CacheStrategy } from './types'; 2 | 3 | export const CACHE_STRATEGIES: { [key: string]: CacheStrategy } = { 4 | CONTEXT_ONLY: 'context-only', 5 | CONTEXT_AND_VARIABLES: 'context-and-variables' 6 | }; 7 | 8 | export const LOAD_POLICIES: { [key: string]: LoadPolicy } = { 9 | CACHE_FIRST: 'cache-first', 10 | CACHE_AND_LOAD: 'cache-and-load', 11 | LOAD_ONLY: 'load-only', 12 | CACHE_ONLY: 'cache-only' 13 | }; 14 | 15 | export const STATES: { [key: string]: LoadingState } = { 16 | IDLE: 'idle', 17 | PENDING: 'pending', 18 | PENDING_SLOW: 'pending-slow', 19 | RESOLVED: 'resolved', 20 | REJECTED: 'rejected', 21 | RELOADING: 'reloading', 22 | RELOADING_SLOW: 'reloading-slow' 23 | }; 24 | -------------------------------------------------------------------------------- /src/createResource.ts: -------------------------------------------------------------------------------- 1 | import { LoadFunction, LoadsConfig } from './types'; 2 | import { preload } from './preload'; 3 | import { useLoads } from './useLoads'; 4 | import { useDeferredLoads } from './useDeferredLoads'; 5 | 6 | type ResourceOptions = { 7 | context: string; 8 | [loadKey: string]: LoadFunction | [LoadFunction, LoadsConfig | undefined] | string; 9 | }; 10 | 11 | function createLoadsHooks(opts: ResourceOptions) { 12 | return Object.entries(opts).reduce((currentLoaders, [loadKey, val]) => { 13 | if (loadKey[0] === '_' || typeof val === 'string') return currentLoaders; 14 | 15 | let loader = val as LoadFunction; 16 | let config = {}; 17 | if (Array.isArray(val)) { 18 | loader = val[0]; 19 | config = val[1] || {}; 20 | } 21 | 22 | if (loadKey === 'fn') { 23 | return { 24 | ...currentLoaders, 25 | preload: (loadsConfig: LoadsConfig | undefined) => 26 | preload(opts.context, loader, { ...config, ...loadsConfig }), 27 | useLoads: (loadsConfig: LoadsConfig | undefined) => 28 | useLoads(opts.context, loader, { ...config, ...loadsConfig }), 29 | useDeferredLoads: (loadsConfig: LoadsConfig | undefined) => 30 | useDeferredLoads(opts.context, loader, { ...config, ...loadsConfig }) 31 | }; 32 | } 33 | 34 | return { 35 | ...currentLoaders, 36 | [loadKey]: { 37 | preload: (loadsConfig: LoadsConfig | undefined) => 38 | preload(opts.context, loader, { ...config, ...loadsConfig }), 39 | useLoads: (loadsConfig: LoadsConfig | undefined) => 40 | useLoads(opts.context, loader, { ...config, ...loadsConfig }), 41 | useDeferredLoads: (loadsConfig: LoadsConfig | undefined) => 42 | useDeferredLoads(opts.context, loader, { ...config, ...loadsConfig }) 43 | } 44 | }; 45 | }, {}); 46 | } 47 | 48 | export function createResource(opts: ResourceOptions) { 49 | return { 50 | ...createLoadsHooks(opts) 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useDetectMounted.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function useDetectMounted() { 4 | const hasMounted = React.useRef(true); 5 | const hasRendered = React.useRef(false); 6 | 7 | React.useEffect(() => { 8 | hasRendered.current = true; 9 | return function cleanup() { 10 | hasMounted.current = false; 11 | }; 12 | }, []); 13 | 14 | return [hasMounted, hasRendered]; 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | // @ts-ignore 4 | const noop = (...args: Array) => {}; 5 | 6 | export default function useInterval(callback: () => void, delay: number | void) { 7 | const savedCallback = React.useRef(noop); 8 | 9 | React.useEffect( 10 | () => { 11 | savedCallback.current = callback; 12 | }, 13 | [callback] 14 | ); 15 | 16 | React.useEffect( 17 | () => { 18 | const handler = (...args: Array) => savedCallback.current(...args); 19 | 20 | if (typeof delay === 'number') { 21 | const id = setInterval(handler, delay); 22 | return () => clearInterval(id); 23 | } 24 | return; 25 | }, 26 | [delay] 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function usePrevious(value: any) { 4 | const ref = React.useRef(); 5 | 6 | React.useEffect( 7 | () => { 8 | ref.current = value; 9 | }, 10 | [value] 11 | ); 12 | 13 | return ref.current; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useTimeout.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function useTimeout() { 4 | const timeout = React.useRef(undefined); 5 | 6 | const _setTimeout = React.useCallback((fn, ms: number) => { 7 | // @ts-ignore 8 | timeout.current = setTimeout(fn, ms); 9 | }, []); 10 | const _clearTimeout = React.useCallback(() => clearTimeout(timeout.current), []); 11 | 12 | React.useEffect(() => { 13 | return function cleanup() { 14 | if (timeout) { 15 | clearTimeout(timeout.current); 16 | } 17 | }; 18 | }, []); 19 | 20 | return [_setTimeout, _clearTimeout]; 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as cache from './cache'; 2 | 3 | export { cache }; 4 | 5 | export * from './cache'; 6 | export * from './createResource'; 7 | export * from './Loads'; 8 | export * from './LoadsConfig'; 9 | export * from './preload'; 10 | export * from './useCache'; 11 | export * from './useLoads'; 12 | export * from './useDeferredLoads'; 13 | export * from './useGetStates'; 14 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import * as cache from './cache'; 2 | import { STATES, LOAD_POLICIES } from './constants'; 3 | import { defaultConfig } from './LoadsConfig'; 4 | import { useLoads } from './useLoads'; 5 | import * as utils from './utils'; 6 | import { ContextArg, ConfigArg, FnArg, LoadFunction, LoadingState, OptimisticCallback, Record } from './types'; 7 | 8 | export function preload( 9 | context: ContextArg, 10 | promiseOrFn: FnArg, 11 | localConfig?: ConfigArg 12 | ) { 13 | const config = { ...defaultConfig, ...(localConfig || {}) }; 14 | const { 15 | cacheTime, 16 | cacheProvider, 17 | cacheStrategy, 18 | dedupingInterval, 19 | loadPolicy, 20 | onResolve, 21 | onReject, 22 | rejectRetryInterval, 23 | revalidateTime, 24 | suspense, 25 | throwError, 26 | variables 27 | } = config; 28 | 29 | function setData({ cacheKey, record }: { cacheKey: string; record: Record }) { 30 | const config = { 31 | cacheProvider, 32 | cacheTime 33 | }; 34 | cache.records.set(cacheKey, record, config); 35 | } 36 | 37 | let count = 0; 38 | function load() { 39 | count = count + 1; 40 | 41 | let args = variables || []; 42 | if (typeof args === 'function') { 43 | try { 44 | args = args() || []; 45 | } catch (err) { 46 | throw new Error('TODO'); 47 | } 48 | } 49 | 50 | const variablesHash = JSON.stringify(args); 51 | const cacheKey = utils.getCacheKey({ context, variablesHash, cacheStrategy }); 52 | 53 | if (!cacheKey) throw new Error('preload() must have a context'); 54 | 55 | let cachedRecord: any; 56 | if (loadPolicy !== LOAD_POLICIES.LOAD_ONLY) { 57 | cachedRecord = cache.records.get(cacheKey); 58 | } 59 | 60 | if (cachedRecord) { 61 | // @ts-ignore 62 | const isStale = Math.abs(new Date() - cachedRecord.updated) >= revalidateTime; 63 | const isDuplicate = 64 | // @ts-ignore 65 | Math.abs(new Date() - cachedRecord.updated) < dedupingInterval; 66 | const isCachedWithCacheFirst = !isStale && loadPolicy === LOAD_POLICIES.CACHE_FIRST; 67 | if (isDuplicate || isCachedWithCacheFirst) return; 68 | } 69 | 70 | let promise = promiseOrFn(...args); 71 | if (typeof promise === 'function') { 72 | promise = promise({ cachedRecord }); 73 | } 74 | 75 | const isReloading = Boolean(cachedRecord); 76 | cache.records.set( 77 | cacheKey, 78 | record => ({ 79 | ...record, 80 | state: cachedRecord ? STATES.RELOADING : STATES.PENDING 81 | }), 82 | { cacheTime, cacheProvider } 83 | ); 84 | if (!isReloading) { 85 | cache.promises.set(cacheKey, promise); 86 | } 87 | 88 | if (typeof promise === 'function') return; 89 | promise 90 | .then(response => { 91 | const record = { error: undefined, response, state: STATES.RESOLVED }; 92 | setData({ cacheKey, record }); 93 | 94 | onResolve && onResolve(response); 95 | }) 96 | .catch(error => { 97 | const record = { response: undefined, error, state: STATES.REJECTED }; 98 | setData({ cacheKey, record }); 99 | 100 | onReject && onReject(error); 101 | 102 | if (rejectRetryInterval) { 103 | const attemptCount = Math.min(count || 0, 8); 104 | const timeout = 105 | typeof rejectRetryInterval === 'function' 106 | ? rejectRetryInterval(attemptCount) 107 | : ~~((Math.random() + 0.5) * (1 << attemptCount)) * rejectRetryInterval; 108 | setTimeout(() => load(), timeout); 109 | } 110 | 111 | if (throwError && !suspense) { 112 | throw error; 113 | } 114 | }) 115 | .finally(() => { 116 | cache.promises.delete(cacheKey); 117 | }); 118 | } 119 | load(); 120 | 121 | return { 122 | useLoads: (loadsConfig: ConfigArg = {}) => 123 | useLoads(context, promiseOrFn, { ...config, ...loadsConfig, loadPolicy: LOAD_POLICIES.CACHE_ONLY }) 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type CacheProvider = { 2 | get: (key: string) => any; 3 | set: (key: string, value: any) => void; 4 | clear: () => void; 5 | delete: (key: string) => void; 6 | }; 7 | export type CacheStrategy = 'context-and-variables' | 'context-only'; 8 | export type LoadsConfig = { 9 | cacheProvider?: CacheProvider; 10 | cacheStrategy?: CacheStrategy; 11 | cacheTime?: number; 12 | context?: string; 13 | debounce?: number; 14 | debounceCache?: boolean; 15 | dedupingInterval?: number; 16 | dedupeManualLoad?: boolean; 17 | delay?: number; 18 | defer?: boolean; 19 | initialResponse?: Response; 20 | loadPolicy?: LoadPolicy; 21 | onReject?: (error: Err) => void; 22 | onResolve?: (response: Response) => void; 23 | pollingInterval?: number; 24 | pollWhile?: boolean | ((record: Record) => boolean); 25 | pollWhenHidden?: boolean; 26 | rejectRetryInterval?: number | ((count: number) => number); 27 | revalidateTime?: number; 28 | revalidateOnWindowFocus?: boolean; 29 | suspense?: boolean; 30 | throwError?: boolean; 31 | timeout?: number; 32 | update?: LoadFunction; 33 | variables?: Array | (() => Array); 34 | }; 35 | export type LoadFunction = (...opts: any) => Promise; 36 | export type LoadPolicy = 'cache-first' | 'cache-and-load' | 'load-only' | 'cache-only'; 37 | export type LoadingState = 38 | | 'idle' 39 | | 'pending' 40 | | 'pending-slow' 41 | | 'resolved' 42 | | 'rejected' 43 | | 'reloading' 44 | | 'reloading-slow'; 45 | export type Loaders = { 46 | [loadKey: string]: LoadFunction | [LoadFunction, LoadsConfig | undefined]; 47 | }; 48 | export type OptimisticCallback = (newData: any) => void; 49 | export type OptimisticContext = { context: string; variables?: Array }; 50 | export type OptimisticOpts = { 51 | context?: LoadsConfig['context']; 52 | }; 53 | export type Record = { 54 | error: Err | undefined; 55 | response: Response | undefined; 56 | state: LoadingState; 57 | isCached?: boolean; 58 | cacheTimeout?: any; 59 | updated?: Date; 60 | }; 61 | export type ResponseRecord = { 62 | load: (...args: any) => Promise; 63 | update: 64 | | ((...args: any) => Promise) 65 | | Array<(...args: any) => Promise>; 66 | reset: () => void; 67 | response: Response | undefined; 68 | error: Err | undefined; 69 | state: LoadingState; 70 | isCached: boolean; 71 | isIdle: boolean; 72 | isPending: boolean; 73 | isPendingSlow: boolean; 74 | isReloading: boolean; 75 | isReloadingSlow: boolean; 76 | isResolved: boolean; 77 | isRejected: boolean; 78 | }; 79 | export type ContextArg = string | Array; 80 | export type FnArg = LoadFunction | ((args?: any) => LoadFunction); 81 | export type ConfigArg = LoadsConfig; 82 | -------------------------------------------------------------------------------- /src/useCache.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import * as cache from './cache'; 4 | import { ContextArg, ConfigArg } from './types'; 5 | 6 | export function useCache(context: ContextArg, { variables }: { variables?: Array } = {}) { 7 | let cacheKey = Array.isArray(context) ? context.join('.') : context; 8 | const variablesHash = React.useMemo(() => JSON.stringify(variables), [variables]); 9 | if (variablesHash) { 10 | cacheKey = `${cacheKey}.${variablesHash}`; 11 | } 12 | 13 | const [record, setRecord] = React.useState(() => cache.records.get(cacheKey)); 14 | 15 | const handleData = React.useCallback(({ record }) => { 16 | setRecord(record); 17 | }, []); 18 | 19 | React.useEffect(() => { 20 | const updaters = cache.updaters.get(cacheKey); 21 | if (updaters) { 22 | const newUpdaters = [...updaters, handleData]; 23 | cache.updaters.set(cacheKey, newUpdaters); 24 | } else { 25 | cache.updaters.set(cacheKey, [handleData]); 26 | } 27 | }); 28 | 29 | return record; 30 | } 31 | -------------------------------------------------------------------------------- /src/useDeferredLoads.ts: -------------------------------------------------------------------------------- 1 | import { useLoads } from './useLoads'; 2 | import { ContextArg, FnArg, ConfigArg } from './types'; 3 | 4 | export function useDeferredLoads( 5 | contextOrFn: ContextArg | FnArg, 6 | fnOrConfig?: FnArg | ConfigArg, 7 | maybeConfig?: ConfigArg 8 | ) { 9 | let context = contextOrFn as ContextArg | null; 10 | let config = maybeConfig; 11 | 12 | let fn = fnOrConfig as FnArg; 13 | if (typeof contextOrFn === 'function') { 14 | context = null; 15 | fn = contextOrFn; 16 | } 17 | 18 | if (typeof fnOrConfig === 'object') { 19 | config = fnOrConfig; 20 | } 21 | 22 | return useLoads(context, fn, { ...config, defer: true }); 23 | } 24 | -------------------------------------------------------------------------------- /src/useGetStates.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function useGetStates(...records: any[]) { 4 | const isIdle = React.useMemo(() => !records.some(record => !record.isIdle), [records]); 5 | const isPending = React.useMemo(() => records.some(record => record.isPending), [records]); 6 | const isPendingSlow = React.useMemo(() => records.some(record => record.isPendingSlow), [records]); 7 | const isResolved = React.useMemo(() => !records.some(record => !record.isResolved && !record.isIdle), [records]); 8 | const isRejected = React.useMemo(() => records.some(record => record.isRejected), [records]); 9 | const isReloading = React.useMemo(() => records.some(record => record.isReloading), [records]); 10 | const isReloadingSlow = React.useMemo(() => records.some(record => record.isReloadingSlow), [records]); 11 | 12 | return React.useMemo( 13 | () => ({ 14 | isIdle, 15 | isPending, 16 | isPendingSlow, 17 | isResolved, 18 | isRejected, 19 | isReloading, 20 | isReloadingSlow 21 | }), 22 | [isIdle, isPending, isPendingSlow, isRejected, isReloading, isReloadingSlow, isResolved] 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/useLoads.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Context as LoadsConfigContext } from './LoadsConfig'; 4 | import * as cache from './cache'; 5 | import { LOAD_POLICIES, STATES } from './constants'; 6 | import useDetectMounted from './hooks/useDetectMounted'; 7 | import useInterval from './hooks/useInterval'; 8 | import usePrevious from './hooks/usePrevious'; 9 | import useTimeout from './hooks/useTimeout'; 10 | import * as utils from './utils'; 11 | import { 12 | ContextArg, 13 | ConfigArg, 14 | FnArg, 15 | LoadFunction, 16 | LoadingState, 17 | OptimisticCallback, 18 | Record, 19 | OptimisticContext 20 | } from './types'; 21 | 22 | function broadcastChanges(cacheKey: string, record: Record) { 23 | const updaters = cache.updaters.get(cacheKey); 24 | if (updaters) { 25 | updaters.forEach((updater: any) => updater({ record, shouldBroadcast: false })); 26 | } 27 | } 28 | 29 | const IDLE_RECORD = { error: undefined, response: undefined, state: STATES.IDLE }; 30 | 31 | export function useLoads( 32 | context: ContextArg | null, 33 | fn: FnArg, 34 | localConfig?: ConfigArg 35 | ) { 36 | const globalConfig = React.useContext(LoadsConfigContext); 37 | const config = { ...globalConfig, ...(localConfig || {}) }; 38 | const { 39 | cacheProvider, 40 | cacheStrategy, 41 | cacheTime, 42 | debounce, 43 | debounceCache, 44 | dedupingInterval, 45 | dedupeManualLoad, 46 | delay, 47 | initialResponse: _initialResponse, 48 | loadPolicy, 49 | onReject, 50 | onResolve, 51 | pollingInterval, 52 | pollWhile, 53 | pollWhenHidden, 54 | rejectRetryInterval, 55 | revalidateOnWindowFocus, 56 | revalidateTime, 57 | suspense, 58 | throwError, 59 | timeout, 60 | update: updateFn 61 | } = config; 62 | 63 | let defer = config.defer; 64 | let variables = config.variables; 65 | if (typeof variables === 'function') { 66 | try { 67 | variables = variables(); 68 | defer = config.defer; 69 | } catch (err) { 70 | defer = true; 71 | } 72 | } 73 | 74 | const variablesHash = React.useMemo(() => JSON.stringify(variables), [variables]); 75 | const cacheKey = utils.getCacheKey({ context, variablesHash, cacheStrategy }); 76 | 77 | const loadCount = React.useRef(0); 78 | const debounceCount = React.useRef(0); 79 | const prevCacheKey = usePrevious(cacheKey); 80 | const isSameContext = !prevCacheKey || prevCacheKey === cacheKey; 81 | const prevVariablesHash = usePrevious(JSON.stringify(variables)); 82 | const isSameVariables = variablesHash === prevVariablesHash; 83 | const [hasMounted, hasRendered] = useDetectMounted(); 84 | const [setDelayTimeout, clearDelayTimeout] = useTimeout(); 85 | const [setErrorRetryTimeout] = useTimeout(); 86 | const [setTimeoutTimeout, clearTimeoutTimeout] = useTimeout(); 87 | 88 | const cachedRecord = React.useMemo( 89 | () => { 90 | if (cacheKey && loadPolicy !== LOAD_POLICIES.LOAD_ONLY) { 91 | return cache.records.get(cacheKey, { cacheProvider }); 92 | } 93 | return; 94 | }, 95 | [cacheProvider, cacheKey, loadPolicy] 96 | ); 97 | 98 | const initialResponse = React.useMemo(() => _initialResponse, []); // eslint-disable-line 99 | let initialRecord: Record = initialResponse 100 | ? { response: initialResponse, error: undefined, state: STATES.RESOLVED } 101 | : { ...IDLE_RECORD, state: defer ? STATES.IDLE : STATES.PENDING }; 102 | if (cachedRecord && !defer) { 103 | initialRecord = cachedRecord; 104 | } 105 | 106 | const reducer = React.useCallback( 107 | ( 108 | state: Record, 109 | action: { type: LoadingState; isCached?: boolean; response?: Response; error?: Err } 110 | ): Record => { 111 | switch (action.type) { 112 | case STATES.IDLE: 113 | return IDLE_RECORD; 114 | case STATES.PENDING: 115 | return { ...state, state: STATES.PENDING }; 116 | case STATES.PENDING_SLOW: 117 | return { ...state, state: STATES.PENDING_SLOW }; 118 | case STATES.RESOLVED: 119 | return { isCached: action.isCached, error: undefined, response: action.response, state: STATES.RESOLVED }; 120 | case STATES.REJECTED: 121 | return { isCached: action.isCached, error: action.error, response: undefined, state: STATES.REJECTED }; 122 | case STATES.RELOADING: 123 | return { ...state, state: STATES.RELOADING }; 124 | case STATES.RELOADING_SLOW: 125 | return { ...state, state: STATES.RELOADING_SLOW }; 126 | default: 127 | return state; 128 | } 129 | }, 130 | [] 131 | ); 132 | const [record, dispatch] = React.useReducer(reducer, initialRecord); 133 | 134 | const handleLoading = React.useCallback( 135 | ({ isReloading, isSlow, promise }) => { 136 | const reloadingState = isSlow ? STATES.RELOADING_SLOW : STATES.RELOADING; 137 | const pendingState = isSlow ? STATES.PENDING_SLOW : STATES.PENDING; 138 | dispatch({ type: isReloading ? reloadingState : pendingState }); 139 | if (cacheKey) { 140 | cache.records.set( 141 | cacheKey, 142 | record => ({ 143 | ...record, 144 | state: isReloading ? STATES.RELOADING : STATES.PENDING 145 | }), 146 | { cacheTime, cacheProvider } 147 | ); 148 | if (!isReloading) { 149 | cache.promises.set(cacheKey, promise); 150 | } 151 | } 152 | }, 153 | [cacheProvider, cacheTime, cacheKey] 154 | ); 155 | 156 | const handleData = React.useCallback( 157 | ({ 158 | count, 159 | record, 160 | shouldBroadcast 161 | }: { 162 | count?: number; 163 | record: Record; 164 | shouldBroadcast: boolean; 165 | }) => { 166 | if (hasMounted.current && (!count || count === loadCount.current)) { 167 | // @ts-ignore 168 | clearDelayTimeout(); 169 | // @ts-ignore 170 | clearTimeoutTimeout(); 171 | dispatch({ 172 | type: record.state, 173 | isCached: Boolean(cacheKey), 174 | ...record 175 | }); 176 | if (cacheKey) { 177 | cache.records.set(cacheKey, record, { 178 | cacheProvider, 179 | cacheTime 180 | }); 181 | 182 | const isSuspended = cache.suspenders.get(cacheKey); 183 | cache.suspenders.set(cacheKey, typeof isSuspended === 'undefined'); 184 | 185 | cache.promises.delete(cacheKey); 186 | 187 | if (shouldBroadcast) { 188 | broadcastChanges(cacheKey, record); 189 | } 190 | } 191 | } 192 | }, 193 | [cacheProvider, cacheTime, clearDelayTimeout, clearTimeoutTimeout, cacheKey, hasMounted] 194 | ); 195 | 196 | const handleOptimisticData = React.useCallback( 197 | ( 198 | { 199 | data, 200 | contextOrCallback, 201 | callback 202 | }: { 203 | data: any; 204 | contextOrCallback?: OptimisticContext | OptimisticCallback; 205 | callback?: OptimisticCallback; 206 | }, 207 | state: LoadingState, 208 | count: number 209 | ) => { 210 | let newData = data; 211 | 212 | let optimisticCacheKey = cacheKey; 213 | if (typeof contextOrCallback === 'object') { 214 | const variablesHash = JSON.stringify(contextOrCallback.variables); 215 | optimisticCacheKey = utils.getCacheKey({ context: contextOrCallback.context, variablesHash, cacheStrategy }); 216 | } 217 | 218 | if (typeof data === 'function') { 219 | let cachedValue = IDLE_RECORD; 220 | if (optimisticCacheKey) { 221 | cachedValue = cache.records.get(optimisticCacheKey, { cacheProvider }) || IDLE_RECORD; 222 | } 223 | newData = data(state === STATES.RESOLVED ? cachedValue.response : cachedValue.error); 224 | } 225 | 226 | const newRecord = { 227 | error: state === STATES.REJECTED ? newData : undefined, 228 | response: state === STATES.RESOLVED ? newData : undefined, 229 | state 230 | }; 231 | if (!optimisticCacheKey || cacheKey === optimisticCacheKey) { 232 | handleData({ count, record: newRecord, shouldBroadcast: true }); 233 | } else { 234 | cache.records.set(optimisticCacheKey, newRecord, { cacheProvider, cacheTime }); 235 | } 236 | 237 | let newCallback = typeof contextOrCallback === 'function' ? contextOrCallback : callback; 238 | newCallback && newCallback(newData); 239 | }, 240 | [cacheStrategy, cacheKey, cacheProvider, handleData, cacheTime] 241 | ); 242 | 243 | const load = React.useCallback( 244 | ( 245 | opts: { 246 | count?: number; 247 | isManualInvoke?: boolean; 248 | setInvocationTimestamp?: boolean; 249 | fn?: LoadFunction; 250 | } = {} 251 | ) => { 252 | return (..._args: any) => { 253 | const { setInvocationTimestamp = true } = opts; 254 | if (!opts.isManualInvoke && variables && isSameVariables) { 255 | return; 256 | } 257 | 258 | // Build cacheKey based of these args? 259 | let args = _args.filter((arg: any) => arg.constructor.name !== 'Class'); 260 | if (variables && (!args || args.length === 0)) { 261 | args = variables; 262 | } 263 | 264 | if (context && debounce > 0) { 265 | const now = new Date().getTime(); 266 | 267 | if (setInvocationTimestamp) { 268 | cache.invocationTimestamps.set(context, now); 269 | cache.invocationTimestamps.set('latest', now); 270 | } 271 | const latestInvocationTimestamp = cache.invocationTimestamps.get(context || 'latest'); 272 | 273 | if (latestInvocationTimestamp) { 274 | if (Math.abs(now - latestInvocationTimestamp) < debounce) { 275 | debounceCount.current = debounceCount.current + 1; 276 | setTimeout( 277 | () => load({ count: debounceCount.current, setInvocationTimestamp: false, ...opts })(..._args), 278 | debounce 279 | ); 280 | return; 281 | } 282 | if (debounceCount.current !== (opts.count || 0)) { 283 | return; 284 | } 285 | } 286 | debounceCount.current = 0; 287 | } 288 | 289 | let cachedRecord; 290 | if (cacheKey) { 291 | cachedRecord = cache.records.get(cacheKey, { cacheProvider }); 292 | } 293 | 294 | if (cachedRecord) { 295 | const isDuplicate = 296 | // @ts-ignore 297 | Math.abs(new Date() - cachedRecord.updated) < dedupingInterval && 298 | (!opts.isManualInvoke || dedupeManualLoad); 299 | if (isDuplicate) return; 300 | } 301 | 302 | loadCount.current = loadCount.current + 1; 303 | const count = loadCount.current; 304 | 305 | if (cacheKey) { 306 | const isSuspended = cache.suspenders.get(cacheKey); 307 | if (suspense && isSuspended) { 308 | cache.suspenders.set(cacheKey, false); 309 | return; 310 | } 311 | } 312 | 313 | if (cacheKey && loadPolicy !== LOAD_POLICIES.LOAD_ONLY) { 314 | if (!defer && cachedRecord) { 315 | dispatch({ type: cachedRecord.state, isCached: true, ...cachedRecord }); 316 | 317 | if (cachedRecord.state === STATES.RESOLVED || cachedRecord.state === STATES.REJECTED) { 318 | // @ts-ignore 319 | const isStale = Math.abs(new Date() - cachedRecord.updated) >= revalidateTime; 320 | const isCachedWithCacheFirst = 321 | !isStale && !opts.isManualInvoke && loadPolicy === LOAD_POLICIES.CACHE_FIRST; 322 | if (isCachedWithCacheFirst) return; 323 | } 324 | } 325 | } 326 | 327 | const loadFn = opts.fn ? opts.fn : fn; 328 | const promiseOrFn = loadFn(...args); 329 | 330 | let promise = promiseOrFn; 331 | if (typeof promiseOrFn === 'function') { 332 | promise = promiseOrFn({ 333 | cachedRecord, 334 | setResponse: ( 335 | data: any, 336 | contextOrCallback: OptimisticContext | OptimisticCallback, 337 | callback?: OptimisticCallback 338 | ) => handleOptimisticData({ data, contextOrCallback, callback }, STATES.RESOLVED, count), 339 | setError: ( 340 | data: any, 341 | contextOrCallback: OptimisticContext | OptimisticCallback, 342 | callback?: OptimisticCallback 343 | ) => handleOptimisticData({ data, contextOrCallback, callback }, STATES.REJECTED, count) 344 | }); 345 | } 346 | 347 | const isReloading = 348 | (context && isSameContext && (count > 1 || (cachedRecord && !defer) || initialResponse)) || 349 | (debounce > 0 && debounceCache); 350 | if (delay > 0) { 351 | setDelayTimeout(() => handleLoading({ isReloading, promise }), delay); 352 | } else { 353 | handleLoading({ isReloading, promise }); 354 | } 355 | if (timeout > 0) { 356 | setTimeoutTimeout(() => handleLoading({ isReloading, isSlow: true, promise }), timeout); 357 | } 358 | 359 | if (typeof promise === 'function') return; 360 | return promise 361 | .then(response => { 362 | handleData({ 363 | count, 364 | record: { error: undefined, response, state: STATES.RESOLVED }, 365 | shouldBroadcast: true 366 | }); 367 | 368 | onResolve && onResolve(response); 369 | 370 | return response; 371 | }) 372 | .catch(error => { 373 | handleData({ 374 | count, 375 | record: { response: undefined, error, state: STATES.REJECTED }, 376 | shouldBroadcast: false 377 | }); 378 | 379 | onReject && onReject(error); 380 | 381 | if (rejectRetryInterval) { 382 | const count = Math.min(loadCount.current || 0, 8); 383 | const timeout = 384 | typeof rejectRetryInterval === 'function' 385 | ? rejectRetryInterval(count) 386 | : ~~((Math.random() + 0.5) * (1 << count)) * rejectRetryInterval; 387 | setErrorRetryTimeout(() => load()(args), timeout); 388 | } 389 | 390 | if (throwError && !suspense) { 391 | throw error; 392 | } 393 | }); 394 | }; 395 | }, 396 | [ 397 | variablesHash, 398 | isSameVariables, 399 | context, 400 | debounce, 401 | cacheKey, 402 | loadPolicy, 403 | fn, 404 | isSameContext, 405 | defer, 406 | initialResponse, 407 | debounceCache, 408 | delay, 409 | timeout, 410 | cacheProvider, 411 | dedupingInterval, 412 | dedupeManualLoad, 413 | suspense, 414 | revalidateTime, 415 | handleOptimisticData, 416 | setDelayTimeout, 417 | handleLoading, 418 | setTimeoutTimeout, 419 | handleData, 420 | onResolve, 421 | onReject, 422 | rejectRetryInterval, 423 | throwError, 424 | setErrorRetryTimeout 425 | ] 426 | ); 427 | 428 | const update = React.useMemo( 429 | () => { 430 | if (!updateFn) return; 431 | if (Array.isArray(updateFn)) { 432 | return updateFn.map(fn => load({ fn, isManualInvoke: true })); 433 | } 434 | return load({ fn: updateFn, isManualInvoke: true }); 435 | }, 436 | [load, updateFn] 437 | ); 438 | 439 | const reset = React.useCallback(() => { 440 | dispatch({ type: STATES.IDLE }); 441 | }, []); 442 | 443 | React.useEffect( 444 | () => { 445 | if (!cachedRecord && cacheKey && !initialResponse && !debounceCache) { 446 | reset(); 447 | } 448 | }, 449 | [cachedRecord, cacheKey, initialResponse, reset, debounceCache] 450 | ); 451 | 452 | React.useEffect( 453 | () => { 454 | if (cachedRecord && !defer && loadPolicy !== LOAD_POLICIES.LOAD_ONLY) { 455 | dispatch({ type: cachedRecord.state, isCached: true, ...cachedRecord }); 456 | } 457 | }, 458 | [cachedRecord, loadPolicy, dispatch, defer] 459 | ); 460 | 461 | React.useEffect( 462 | () => { 463 | if (defer || (suspense && (!hasRendered.current && !cachedRecord)) || loadPolicy === LOAD_POLICIES.CACHE_ONLY) 464 | return; 465 | load()(); 466 | }, 467 | [defer, cacheKey, suspense, hasRendered, cachedRecord, load, loadPolicy] 468 | ); 469 | 470 | React.useEffect( 471 | () => { 472 | if (defer) return; 473 | 474 | const updaters = cache.updaters.get(cacheKey); 475 | if (updaters) { 476 | const newUpdaters = [...updaters, handleData]; 477 | cache.updaters.set(cacheKey, newUpdaters); 478 | } else { 479 | cache.updaters.set(cacheKey, [handleData]); 480 | } 481 | 482 | return function cleanup() { 483 | const updaters = cache.updaters.get(cacheKey); 484 | const newUpdaters = updaters.filter((updater: any) => updater !== handleData); 485 | cache.updaters.set(cacheKey, newUpdaters); 486 | }; 487 | }, 488 | [cacheKey, defer, handleData] 489 | ); 490 | 491 | React.useEffect( 492 | () => { 493 | if (!revalidateOnWindowFocus || defer) return; 494 | 495 | const revalidate = load(); 496 | cache.revalidators.set(cacheKey, revalidate); 497 | 498 | return function cleanup() { 499 | cache.revalidators.delete(cacheKey); 500 | }; 501 | }, 502 | [cacheKey, defer, handleData, load, revalidateOnWindowFocus] 503 | ); 504 | 505 | let shouldPoll = !defer; 506 | if (shouldPoll && pollWhile) { 507 | if (typeof pollWhile === 'function') { 508 | shouldPoll = pollWhile(record); 509 | } else { 510 | shouldPoll = pollWhile; 511 | } 512 | } 513 | useInterval(() => { 514 | if (!utils.isDocumentVisible() && !pollWhenHidden) return; 515 | load({ isManualInvoke: true })(); 516 | }, shouldPoll ? pollingInterval : undefined); 517 | 518 | const states = React.useMemo( 519 | () => ({ 520 | isIdle: record.state === STATES.IDLE && Boolean(!record.response), 521 | isPending: record.state === STATES.PENDING || record.state === STATES.PENDING_SLOW, 522 | isPendingSlow: record.state === STATES.PENDING_SLOW, 523 | isResolved: record.state === STATES.RESOLVED || Boolean(record.response), 524 | isRejected: record.state === STATES.REJECTED, 525 | isReloading: record.state === STATES.RELOADING || record.state === STATES.RELOADING_SLOW, 526 | isReloadingSlow: record.state === STATES.RELOADING_SLOW 527 | }), 528 | [record.response, record.state] 529 | ); 530 | 531 | if (suspense && !defer) { 532 | if (cacheKey) { 533 | const record = cache.records.get(cacheKey); 534 | const promise = cache.promises.get(cacheKey); 535 | if (record && promise) { 536 | throw promise; 537 | } 538 | if (!record) { 539 | load()(); 540 | } 541 | } 542 | if (states.isRejected) { 543 | throw record.error; 544 | } 545 | } 546 | 547 | return React.useMemo( 548 | () => { 549 | return { 550 | load: load({ isManualInvoke: true }), 551 | update, 552 | reset, 553 | 554 | response: record.response, 555 | error: record.error, 556 | state: record.state, 557 | 558 | ...states, 559 | 560 | isCached: Boolean(record.isCached) 561 | }; 562 | }, 563 | [load, update, reset, record.response, record.error, record.state, record.isCached, states] 564 | ); 565 | } 566 | 567 | let eventsBinded = false; 568 | if (typeof window !== 'undefined' && window.addEventListener && !eventsBinded) { 569 | const revalidate = () => { 570 | if (!utils.isDocumentVisible() || !utils.isOnline()) return; 571 | cache.revalidators.forEach(revalidator => revalidator && revalidator()); 572 | }; 573 | window.addEventListener('visibilitychange', revalidate, false); 574 | window.addEventListener('focus', revalidate, false); 575 | eventsBinded = true; 576 | } 577 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_STRATEGIES } from './constants'; 2 | import { ContextArg, CacheStrategy } from './types'; 3 | 4 | export function isDocumentVisible() { 5 | if (typeof document !== 'undefined' && typeof document.visibilityState !== 'undefined') { 6 | return document.visibilityState !== 'hidden'; 7 | } 8 | return true; 9 | } 10 | 11 | export function isOnline() { 12 | if (typeof navigator.onLine !== 'undefined') { 13 | return navigator.onLine; 14 | } 15 | return true; 16 | } 17 | 18 | export function getCacheKey({ 19 | context, 20 | variablesHash, 21 | cacheStrategy 22 | }: { 23 | context: ContextArg | null; 24 | variablesHash: string; 25 | cacheStrategy: CacheStrategy; 26 | }) { 27 | let cacheKey = Array.isArray(context) ? context.join('.') : context; 28 | if (variablesHash && cacheStrategy === CACHE_STRATEGIES.CONTEXT_AND_VARIABLES) { 29 | cacheKey = `${cacheKey}.${variablesHash}`; 30 | } 31 | return cacheKey; 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "exclude": ["src/**/*.test.tsx", "node_modules"], 4 | "compilerOptions": { 5 | "baseUrl": "", 6 | "paths": { 7 | "*": [ "./typings/*" ] 8 | }, 9 | "outDir": "ts", 10 | "target": "esnext", 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "jsx": "react", 14 | "strict": true, 15 | "declaration": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitReturns": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": true, 20 | "stripInternal": true, 21 | "skipLibCheck": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------