├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── run-tests.yml ├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── deploy.mjs ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── blog │ ├── 2019-05-28-first-blog-post.md │ ├── 2019-05-29-long-blog-post.md │ ├── 2021-08-01-mdx-blog-post.mdx │ ├── 2021-08-26-welcome │ │ ├── docusaurus-plushie-banner.jpeg │ │ └── index.md │ └── authors.yml ├── docs │ ├── Features │ │ ├── _category_.json │ │ ├── custom-loader.md │ │ ├── defer-queries.mdx │ │ ├── deferred.png │ │ ├── extending.mdx │ │ ├── index.mdx │ │ ├── other-libs.md │ │ ├── passing-arguments.mdx │ │ ├── stateful-loader.md │ │ └── transforming.md │ ├── Migrations │ │ └── v0.x.md │ ├── Quick Guide │ │ ├── _category_.json │ │ ├── accept-arguments.mdx │ │ ├── add-queries.md │ │ ├── base-loader.mdx │ │ ├── consume-loader.mdx │ │ ├── extend-loader.mdx │ │ ├── extend-loader.png │ │ ├── index.mdx │ │ ├── queriesArg.png │ │ └── rtk-query-loader-chart.png │ ├── Reference │ │ ├── aggregate-to-query.md │ │ ├── awaitloader.md │ │ ├── consumer-props.md │ │ ├── create-loader.md │ │ ├── index.mdx │ │ ├── infer-loader-data.md │ │ ├── use-create-query.md │ │ └── with-loader.md │ ├── examples.mdx │ ├── intro.md │ ├── problem-solve.mdx │ └── terminology.png ├── docusaurus.config.js ├── package-lock.json ├── package.json ├── sidebars.js ├── src │ ├── components │ │ └── HomepageFeatures │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ └── css │ │ └── custom.css ├── static │ ├── .nojekyll │ └── img │ │ ├── docusaurus-social-card.jpg │ │ ├── docusaurus.png │ │ ├── favicon.ico │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── ryfrea-social-card.png │ │ ├── undraw_docusaurus_mountain.svg │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg ├── tsconfig.json └── yarn.lock ├── package-lock.json ├── package.json ├── src ├── AwaitLoader.tsx ├── RTKLoader.tsx ├── aggregateToQuery.ts ├── createLoader.ts ├── createQuery.ts ├── index.ts ├── types.ts ├── useCreateLoader.ts └── withLoader.tsx ├── testing-app ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src │ ├── components │ │ └── ErrorBoundary.tsx │ ├── index.tsx │ ├── mocks.ts │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── store.ts │ ├── testComponents.tsx │ ├── tests.test.tsx │ └── utils.tsx ├── tsconfig.json └── yarn.lock ├── tsconfig.json └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Actual behavior** 24 | A clear and concise description of what actually happened. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Device (please complete the following information):** 30 | - OS: [e.g. iOS] 31 | - Browser [e.g. chrome, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | run-name: Testing out changes 🚀 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build_and_test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node: [10, 12] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Install package 25 | run: yarn install 26 | - name: Install testing-app 27 | working-directory: ./testing-app 28 | run: yarn install 29 | - name: Run tests 30 | working-directory: ./testing-app 31 | run: yarn run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | testing-app 3 | docs -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Thank you for considering contributing to this package**! This small guide will help you get started. 4 | 5 | ## 🍽️ Fork + Clone 6 | 7 | 1. Make a fork of this repository 8 | 2. Navigate to your forked repository and copy the SSH url. Clone your fork locally: 9 | 10 | ```bash 11 | $ git clone git@github.com:{ YOUR_USERNAME }/rtk-query-loader.git 12 | $ cd rtk-query-loader 13 | ``` 14 | 15 | 3. Once cloned, you will see `origin` as your default remote, pointing to your personal forked repository. Add a remote named upstream pointing to the main rtk-query-loader: 16 | 17 | ```bash 18 | $ git remote add upstream git@github.com:ryfylke-react-as/rtk-query-loader.git 19 | $ git remote -v 20 | ``` 21 | 22 | ## ➕ Create a new branch 23 | 24 | 1. First, make sure you have the latest changes: 25 | 26 | ```bash 27 | $ git pull upstream main 28 | ``` 29 | 30 | 2. Create a new feature branch for your changes: 31 | 32 | ```bash 33 | $ git checkout -b { YOUR_BRANCH_NAME } main 34 | ``` 35 | 36 | ## 👩‍💻 Add your changes 37 | 38 | For now, try to following existing patterns. Formalized code-guidelines will come at some point. 39 | 40 | If you have any questions, feel free to open up an issue! 41 | 42 | ## 🩺 Test you changes 43 | 44 | 1. In the `testing-app` directory, install the project dependencies, and then run the tests: 45 | 46 | ```bash 47 | $ yarn install 48 | $ yarn run test 49 | ``` 50 | 51 | 2. If you are adding a new feature, make sure to write a new test for your change in `testing-app/tests.test.tsx` and rerun. 52 | 53 | ## 🤜 Push your changes 54 | If all the tests pass, you can commit your final changes and push! 55 | 56 | ```bash 57 | $ git commit -a -m "feat: withLoader now has a new argument, ..." 58 | $ git push origin { YOUR_BRANCH_NAME } 59 | ``` 60 | 61 | > We prefer it if you try to stick to [conventional commit messages](https://www.conventionalcommits.org/en/v1.0.0/#summary). 62 | 63 | ## 📄 Create a pull-request 64 | 65 | - On Github, navigate to [@ryfylke-react/rtk-query-loader](https://github.com/ryfylke-react-as/rtk-query-loader) and click the button that reads "Compare & pull request". 66 | - Write a title and description, and submit your pull request. 67 | 68 | Your code will be reviewed and if everything looks good you'll be added to the list of contributors. 69 | 70 | Any contribution is appreciated 🤘 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![RTK Query Loader](https://user-images.githubusercontent.com/1190770/233955284-a7da801e-ff3f-4fdc-9808-8f1e5a829012.png) 2 | 3 | ![npm](https://img.shields.io/npm/v/@ryfylke-react/rtk-query-loader?color=gray&style=flat-square) 4 | ![npm type definitions](https://img.shields.io/npm/types/@ryfylke-react/rtk-query-loader?color=gray&label=%20&logoColor=gray) 5 | ![npm bundle size](https://img.shields.io/bundlephobia/min/@ryfylke-react/rtk-query-loader@latest?style=flat-square) 6 | 7 | **RTK Query Loader lets you create query loaders for your React components.** 8 | 9 | > Made for use with RTK Query, but is also [query-agnostic](#features). 10 | 11 | - [Live demo / Playground](https://codesandbox.io/s/rtk-query-loader-demo-42tubp) 12 | - [NPM](https://www.npmjs.com/package/@ryfylke-react/rtk-query-loader) 13 | - [Documentation](https://rtk-query-loader.ryfylke.dev/) 14 | - [Quick Start](https://rtk-query-loader.ryfylke.dev/Quick%20Guide/) 15 | 16 | ## Install 17 | 18 | ```bash 19 | yarn add @ryfylke-react/rtk-query-loader 20 | # or 21 | npm i @ryfylke-react/rtk-query-loader 22 | ``` 23 | 24 | ## Features 25 | 26 | - [x] **Flexible**: You can extend and inherit existing loaders to create new ones. 27 | - [x] **Transformable**: Combine and transform the data from your loaders to your desired format. 28 | - [x] **Query Agnostic**: Can be used with RTK Query, Tanstack Query, JS promises, [and more...](https://rtk-query-loader.ryfylke.dev/Features/other-libs) 29 | - [x] **Configurable**: Exposes important configuration options, all of which are inheritable. 30 | 31 | You can read more about the features @ [the docs](https://rtk-query-loader.ryfylke.dev/Features/). 32 | 33 | 34 | 35 |
36 | 🔬 We're also properly tested! (✓ 30/30) 37 | 38 | --- 39 | 40 | * **aggregateToQuery** 41 | * ✓ It aggregates query status (167 ms) 42 | * **useCreateQuery** 43 | * ✓ It creates a query (107 ms) 44 | * ✓ The query can throw error (108 ms) 45 | * ✓ You can refetch the query (645 ms) 46 | * **** 47 | * ✓ Renders loading state until data is available (130 ms) 48 | * ✓ Will pass arguments properly (129 ms) 49 | * **withLoader** 50 | * ✓ Renders loading state until data is available (132 ms) 51 | * ✓ onError renders when applicable (130 ms) 52 | * ✓ onFetching renders when applicable (319 ms) 53 | * ✓ Internal state won't reset when using whileFetching (272 ms) 54 | * ✓ Internal state will reset when using onFetching (271 ms) 55 | * ✓ Can use custom loader component (129 ms) 56 | * ✓ loaderComponent is backwards compatible (121 ms) 57 | * ✓ Can defer some queries (231 ms) 58 | * ✓ Can defer all queries (130 ms) 59 | * ✓ Loaders with no queries render immediately (4 ms) 60 | * ✓ Can remount a component that has a failed query (161 ms) 61 | * **createLoader** 62 | * ✓ Normally, deferred queries do not throw (205 ms) 63 | * ✓ Deferred queries throw error when configured to (209 ms) 64 | * ✓ Can send static payload to loader (7 ms) 65 | * ✓ Loader passes props through queriesArg to queries (128 ms) 66 | * **.extend()** 67 | * ✓ Can extend onLoading (5 ms) 68 | * ✓ Can extend onError (128 ms) 69 | * ✓ Can extend onFetching (156 ms) 70 | * ✓ Can extend whileFetching (133 ms) 71 | * ✓ Can extend queries (122 ms) 72 | * ✓ Can extend deferred queries (230 ms) 73 | * ✓ Can extend many times (282 ms) 74 | * ✓ Can extend with only transform (133 ms) 75 | * ✓ Can partially extend config (138 ms) 76 | 77 | --- 78 |
79 | 80 | ## Example 81 | A simple example of a component using rtk-query-loader: 82 | 83 | ```tsx 84 | import { 85 | createLoader, 86 | withLoader, 87 | } from "@ryfylke-react/rtk-query-loader"; 88 | 89 | const loader = createLoader({ 90 | useQueries: () => { 91 | const pokemon = useGetPokemon(); 92 | const currentUser = useGetCurrentUser(); 93 | 94 | return { 95 | queries: { 96 | pokemon, 97 | currentUser, 98 | }, 99 | }; 100 | }, 101 | onLoading: () =>
Loading pokemon...
, 102 | }); 103 | 104 | const Pokemon = withLoader((props, loader) => { 105 | const pokemon = loader.queries.pokemon.data; 106 | const currentUser = loader.queries.currentUser.data; 107 | 108 | return ( 109 |
110 |

{pokemon.name}

111 | 112 | 113 | Your pokemon 114 | 115 |
116 | ); 117 | }, loader); 118 | ``` 119 | 120 | ## What problem does this solve? 121 | 122 | Let's say you have a component that depends on data from more than one query. 123 | 124 | ```tsx 125 | function Component(props){ 126 | const userQuery = useGetUser(props.id); 127 | const postsQuery = userGetPostsByUser(userQuery.data?.id, { 128 | skip: user?.data?.id === undefined, 129 | }); 130 | 131 | if (userQuery.isError || postsQuery.isError){ 132 | // handle error 133 | } 134 | 135 | /* possible something like */ 136 | // if (userQuery.isLoading){ return (...) } 137 | 138 | return ( 139 |
140 | {/* or checking if the type is undefined in the jsx */} 141 | {(userQuery.isLoading || postsQuery.isLoading) && (...)} 142 | {userQuery.data && postsQuery.data && (...)} 143 |
144 | ) 145 | } 146 | ``` 147 | 148 | The end result is possibly lots of bloated code that has to take into consideration that the values could be undefined, optional chaining, etc... 149 | 150 | What if we could instead "join" these queries into one, and then just return early if we are in the initial loading stage. That's basically the approach that rtk-query-loader takes. Some pros include: 151 | 152 | - [x] Way less optional chaining in your components 153 | - [x] Better type certainty 154 | - [x] Easy to write re-usable loaders that can be abstracted away from the components 155 | 156 | ## [Documentation](https://rtk-query-loader.ryfylke.dev) 157 | 158 | ## [Quick Guide](https://rtk-query-loader.ryfylke.dev/Quick%20Guide/) 159 | -------------------------------------------------------------------------------- /deploy.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zx 2 | try { 3 | await Promise.all([ 4 | $`yarn build`, 5 | $`npm version patch --force`, 6 | ]); 7 | const version = require("./package.json").version; 8 | await Promise.all([ 9 | $`git commit --allow-empty -m "version: ${version}"`, 10 | $`git push`, 11 | $`npm publish --access public`, 12 | $`echo "======================"`, 13 | $`echo "Deployed! 🚀 (${version})"`, 14 | $`echo "======================"`, 15 | ]); 16 | } catch (err) { 17 | await Promise.all([ 18 | $`echo "======================"`, 19 | $`echo "Deploy failed! 😭"`, 20 | $`echo "======================"`, 21 | $`echo "${err}"`, 22 | ]); 23 | } 24 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/blog/2019-05-28-first-blog-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: first-blog-post 3 | title: First Blog Post 4 | authors: 5 | name: Gao Wei 6 | title: Docusaurus Core Team 7 | url: https://github.com/wgao19 8 | image_url: https://github.com/wgao19.png 9 | tags: [hola, docusaurus] 10 | --- 11 | 12 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 13 | -------------------------------------------------------------------------------- /docs/blog/2019-05-29-long-blog-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: long-blog-post 3 | title: Long Blog Post 4 | authors: endi 5 | tags: [hello, docusaurus] 6 | --- 7 | 8 | This is the summary of a very long blog post, 9 | 10 | Use a `` comment to limit blog post size in the list view. 11 | 12 | 13 | 14 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 17 | 18 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 19 | 20 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 21 | 22 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 23 | 24 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 25 | 26 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 27 | 28 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 29 | 30 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 31 | 32 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 33 | 34 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 35 | 36 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 37 | 38 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 39 | 40 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 41 | 42 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 43 | 44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet 45 | -------------------------------------------------------------------------------- /docs/blog/2021-08-01-mdx-blog-post.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | slug: mdx-blog-post 3 | title: MDX Blog Post 4 | authors: [slorber] 5 | tags: [docusaurus] 6 | --- 7 | 8 | Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/). 9 | 10 | :::tip 11 | 12 | Use the power of React to create interactive blog posts. 13 | 14 | ```js 15 | 16 | ``` 17 | 18 | 19 | 20 | ::: 21 | -------------------------------------------------------------------------------- /docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg -------------------------------------------------------------------------------- /docs/blog/2021-08-26-welcome/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: welcome 3 | title: Welcome 4 | authors: [slorber, yangshun] 5 | tags: [facebook, hello, docusaurus] 6 | --- 7 | 8 | [Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog). 9 | 10 | Simply add Markdown files (or folders) to the `blog` directory. 11 | 12 | Regular blog authors can be added to `authors.yml`. 13 | 14 | The blog post date can be extracted from filenames, such as: 15 | 16 | - `2019-05-30-welcome.md` 17 | - `2019-05-30-welcome/index.md` 18 | 19 | A blog post folder can be convenient to co-locate blog post images: 20 | 21 | ![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg) 22 | 23 | The blog supports tags as well! 24 | 25 | **And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config. 26 | -------------------------------------------------------------------------------- /docs/blog/authors.yml: -------------------------------------------------------------------------------- 1 | endi: 2 | name: Endilie Yacop Sucipto 3 | title: Maintainer of Docusaurus 4 | url: https://github.com/endiliey 5 | image_url: https://github.com/endiliey.png 6 | 7 | yangshun: 8 | name: Yangshun Tay 9 | title: Front End Engineer @ Facebook 10 | url: https://github.com/yangshun 11 | image_url: https://github.com/yangshun.png 12 | 13 | slorber: 14 | name: Sébastien Lorber 15 | title: Docusaurus maintainer 16 | url: https://sebastienlorber.com 17 | image_url: https://github.com/slorber.png 18 | -------------------------------------------------------------------------------- /docs/docs/Features/_category_.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /docs/docs/Features/custom-loader.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # Custom `loaderComponent` 6 | 7 | A `loaderComponent` is the component that determines what to render given the aggregated query status, when using `withLoader`. 8 | 9 | ```typescript title="loaderComponent Props" 10 | export type CustomLoaderProps = { 11 | /** The joined query for the loader */ 12 | query: UseQueryResult; 13 | /** What the loader requests be rendered when data is available */ 14 | onSuccess: (data: T) => React.ReactElement; 15 | /** What the loader requests be rendered when the query fails */ 16 | onError?: ( 17 | error: SerializedError | FetchBaseQueryError 18 | ) => JSX.Element; 19 | /** What the loader requests be rendered while loading data */ 20 | onLoading?: React.ReactElement; 21 | /** What the loader requests be rendered while fetching data */ 22 | onFetching?: React.ReactElement; 23 | /** What the loader requests be rendered while fetching data */ 24 | whileFetching?: { 25 | /** Should be appended to the success result while fetching */ 26 | append?: React.ReactElement; 27 | /** Should be prepended to the success result while fetching */ 28 | prepend?: React.ReactElement; 29 | }; 30 | }; 31 | ``` 32 | 33 | This is what the default `loaderComponent` looks like: 34 | 35 | ```typescript title=RTKLoader.tsx 36 | import { SerializedError } from "@reduxjs/toolkit"; 37 | import * as React from "react"; 38 | import { CustomLoaderProps } from "./types"; 39 | 40 | export function RTKLoader( 41 | props: CustomLoaderProps 42 | ): React.ReactElement { 43 | const shouldLoad = 44 | props.query.isLoading || props.query.isUninitialized; 45 | const hasError = props.query.isError && props.query.error; 46 | const isFetching = props.query.isFetching; 47 | 48 | if (shouldLoad) { 49 | return props.onLoading ?? ; 50 | } 51 | 52 | if (hasError) { 53 | return ( 54 | props.onError?.(props.query.error as SerializedError) ?? ( 55 | 56 | ) 57 | ); 58 | } 59 | 60 | if (isFetching && props.onFetching) { 61 | return props.onFetching; 62 | } 63 | 64 | if (props.query.data !== undefined) { 65 | const prepend = isFetching 66 | ? props.whileFetching?.prepend ?? null 67 | : null; 68 | const append = isFetching 69 | ? props.whileFetching?.append ?? null 70 | : null; 71 | const componentWithData = props.onSuccess(props.query.data); 72 | 73 | return ( 74 | <> 75 | {prepend} 76 | {componentWithData} 77 | {append} 78 | 79 | ); 80 | } 81 | 82 | return ; 83 | } 84 | ``` 85 | 86 | You could pass a custom `loaderComponent` to your loaders, if you'd like: 87 | 88 | ```typescript 89 | const CustomLoader = (props: CustomLoaderProps) => { 90 | // Handle rendering 91 | }; 92 | 93 | const loader = createLoader({ 94 | loaderComponent: CustomLoader, 95 | // ... 96 | }); 97 | ``` 98 | 99 | :::tip 100 | This allows you to have really fine control over how the rendering of components using `withLoader` should work. 101 | ::: 102 | -------------------------------------------------------------------------------- /docs/docs/Features/defer-queries.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Deferring queries 6 | 7 | Say you have a query that takes a long time to resolve. You want to put it in the loader, to co-locate it with the rest of your queries, but you don't want it to affect the loading state and postpone the initial load of the component. This is where `deferredQueries` comes in. 8 | 9 | ## Example 10 | 11 | ```typescript {3-13} 12 | const loader = createLoader({ 13 | useQueries: () => { 14 | const importantQuery = useImportantQuery(); 15 | const slowQuery = useSlowQuery(); 16 | 17 | return { 18 | queries: { 19 | importantQuery, 20 | }, 21 | deferredQueries: { 22 | slowQuery, 23 | }, 24 | }; 25 | }, 26 | transform: (loader) => ({ 27 | important: loader.queries.importantQuery.data, // ImportantQueryResponse 28 | slow: loader.deferredQueries.slowQuery.data, // SlowQueryResponse | undefined 29 | }), 30 | }); 31 | ``` 32 | 33 | Deferred queries timeline 37 | 38 | ## Configure 39 | 40 | > **New in version 1.0.3** 41 | 42 | You can pass a `Config` to your `Loader`s: 43 | 44 | ```typescript {4-8} 45 | const loader = createLoader({ 46 | useQueries: () => ({...}), 47 | onError: () => (...), 48 | config: { 49 | deferred: { 50 | shouldThrowError: true, 51 | }, 52 | } 53 | }) 54 | ``` 55 | 56 | - `shouldThrowError` - Determines whether or not `deferredQueries` should send the component to the `onError` view if one of the deferred queries end up in a error state. 57 | -------------------------------------------------------------------------------- /docs/docs/Features/deferred.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/docs/Features/deferred.png -------------------------------------------------------------------------------- /docs/docs/Features/extending.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # Extend 6 | 7 | You can **extend** existing `Loader`s. This lets you inherit and overwrite properties from an existing `Loader`. 8 | 9 | ## Example 10 | 11 | ```tsx 12 | const parentLoader = createLoader({ 13 | onLoading: () =>
Loading...
14 | }); 15 | 16 | const childLoader = parentLoader.extend({ 17 | useQueries: () => ({...}), 18 | onError: () =>
Error
, 19 | }).extend({ 20 | transform: () => ({...}), 21 | }).extend({ 22 | onLoading: () =>
Overwritten loading...
, 23 | }); 24 | ``` 25 | 26 | ## Separation of concerns 27 | 28 | Its up to you how much you want to separate logic here. Some examples would be... 29 | 30 | - Co-locating loaders in a shared folder 31 | - Co-locating loaders in same file as component 32 | - Co-locating loaders in same directory but in a separate file from the component 33 | 34 | I personally prefer to keep the loaders close to the component, either in a file besides it or directly in the file itself, and then keep a base loader somewhere else to extend from. 35 | 36 | ## Creating a loader hierarchy 37 | 38 | You can extend from any loader, including loaders that have already been extended. This allows you to create a hierarchy of loaders that can be used to share logic between components. 39 | 40 | Loader hierarchy illustration 44 | 45 | ## Tips 46 | 47 | :::caution `.extend` will not merge what two separate `useQueries` returns. 48 | For example, you cannot _just_ inherit the deferredQueries, you must either inherit or overwrite the whole `useQueries` argument. 49 | ::: 50 | 51 | :::tip Reusable transformers 52 | You can extend as many times as you'd like. You can use this feature to easily inject reusable snippets, like transformers. 53 | 54 | ```typescript 55 | type QueryRecord = Record>; 56 | 57 | export const transformData = { 58 | transform: (data: { 59 | queries: QueryRecord; 60 | deferredQueries: QueryRecord; 61 | payload: unknown; 62 | }) => { 63 | // handle transformation in generic way 64 | }, 65 | }; 66 | ``` 67 | 68 | ```typescript 69 | const loader = createLoader({...}).extend(transformData); 70 | ``` 71 | 72 | ::: 73 | -------------------------------------------------------------------------------- /docs/docs/Features/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | import DocCardList from "@theme/DocCardList"; 6 | 7 | # Features 8 | 9 | RTK Query Loader tries to give you all the flexibility you need to create reusable and extendable loaders. 10 | 11 | - Supply as many queries as you'd like. 12 | - Supply queries that [don't affect loading state](defer-queries). 13 | - Send down payloads that contain any static data. 14 | - [Transform](./transforming) the data to your desired output-format. 15 | - Set up [default](../Quick%20Guide/base-loader) loading and error states. 16 | - [Extend](./extending) and re-use existing loaders. 17 | - Create [stateful loaders](./stateful-loader) 18 | 19 | And even if you don't use `RTK Query`... 20 | 21 | - Supply queries that are [just promises](../Reference/use-create-query). 22 | - [Use with other data loading libraries](./other-libs) 23 | 24 | --- 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/docs/Features/other-libs.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Use with other libraries 6 | 7 | You can use RTK Query Loader with most other similar query-fetching libraries. This is possible through the use of _resolvers_. 8 | 9 | :::note 10 | Although `rtk-query-loader` was build with `@reduxjs/toolkit` in mind, the underlying principles can be applied to any similar data loading solution. 11 | ::: 12 | 13 | ## Tanstack Query 14 | 15 | [See example on CodeSandbox](https://codesandbox.io/s/tanstack-query-rtk-query-loader-example-6393w2) 16 | 17 | ```typescript 18 | import { 19 | useQuery, 20 | UseQueryResult as TanstackUseQueryResult, 21 | UseQueryOptions, 22 | } from "@tanstack/react-query"; 23 | 24 | const tanstackResolver = ( 25 | query: TanstackUseQueryResult 26 | ): UseQueryResult & { 27 | original_query: TanstackUseQueryResult; 28 | } => ({ 29 | isLoading: query.isLoading, 30 | isFetching: query.isFetching, 31 | isSuccess: query.isSuccess, 32 | isError: query.isError, 33 | error: query.error, 34 | data: query.data, 35 | isUninitialized: !query.isFetchedAfterMount, 36 | originalArgs: null, 37 | refetch: () => query.refetch(), 38 | currentData: query.data, 39 | endpointName: undefined, 40 | original_query: query, 41 | }); 42 | 43 | const useTanstackQuery = ( 44 | args: UseQueryOptions 45 | ) => tanstackResolver(useQuery(args)); 46 | ``` 47 | 48 | This is how you would use it: 49 | 50 | ```typescript 51 | import { useTanstackQuery } from "../loader-resolvers"; 52 | 53 | const loader = createLoader({ 54 | useQueries: () => { 55 | const repoData = useTanstackQuery({ 56 | queryKey: ["repoData"], 57 | queryFn: () => 58 | fetch( 59 | "https://api.github.com/repos/ryfylke-react-as/rtk-query-loader" 60 | ).then((res) => res.json() as Promise), 61 | }); 62 | 63 | return { 64 | queries: { 65 | repoData, 66 | }, 67 | }; 68 | }, 69 | }); 70 | ``` 71 | 72 | The output format will obviously be a bit different, but in this example you have access to the original query at the `.original_query` property. 73 | 74 | ## Other libraries 75 | 76 | If you are interested in creating resolvers for other libraries, you can [edit this page](https://github.com/ryfylke-react-as/rtk-query-loader/tree/main/docs/docs/Advanced/other-libs.md) and then [submit a pull request](https://github.com/ryfylke-react-as/rtk-query-loader/compare) on GitHub to share your resolver here, as an npm package, or with the code embedded directly in the docs. 77 | 78 | ## Use with promises 79 | 80 | If you want to use RTK Query Loader with promises, you can read more about that on the [`useCreateQuery` reference page](../Reference/use-create-query.md). 81 | -------------------------------------------------------------------------------- /docs/docs/Features/passing-arguments.mdx: -------------------------------------------------------------------------------- 1 | # Passing arguments 2 | 3 | You can setup loaders so that they can be used with arguments. How you pass the arguments depends on how you use the loader. 4 | 5 | You set this up using the `queriesArg` argument. This is a function that takes the consumer's expected props and returns the argument that should be passed to the useQueries hook. 6 | 7 | ```tsx 8 | const loader = createLoader({ 9 | queriesArg: (props: { userId: string }) => props.userId, 10 | useQueries: (userId) => { 11 | // userId is automatically inferred as string 12 | const user = useGetUserQuery(userId); 13 | 14 | return { 15 | queries: { 16 | user, 17 | }, 18 | }; 19 | }, 20 | }); 21 | ``` 22 | 23 | ## Data flow 24 | 25 | The transformation layer between the useQueries hook and the loader is the `queriesArg` function. 26 | 27 | It takes the component's props and returns the argument that should be passed to the useQueries hook. This is nessecary to be able to consume the loader in a type-safe way through withLoader. 28 | 29 | queriesArg 34 | 35 | ## When using `` 36 | 37 | You should pass the expected _props_ to the `arg` prop when using ``. 38 | 39 | ```tsx 40 | (...)} 43 | args={{ 44 | userId: "1234", 45 | }} 46 | /> 47 | ``` 48 | 49 | ## Properly typing the `queriesArg` function 50 | 51 | You can use the `ConsumerProps` utility type to type the `queriesArg` function. 52 | 53 | ```tsx 54 | import { 55 | ConsumerProps, 56 | createLoader, 57 | } from "@ryfylke-react/rtk-query-loader"; 58 | 59 | type UserRouteLoaderProps = ConsumerProps<{ 60 | userId: string; 61 | }>; 62 | 63 | const loader = createLoader({ 64 | queriesArg: (props: UserRouteLoaderProps) => props.userId, 65 | // ... 66 | }); 67 | ``` 68 | 69 | The type utility will ensure that the type can be extended by the consumer components. This means that the following types are both valid for the definition above: 70 | 71 | ```ts 72 | // This is valid ✅ 73 | type Props_1 = { 74 | userId: string; 75 | }; 76 | 77 | // This is also valid ✅ 78 | type Props_2 = { 79 | userId: string; 80 | someOtherProp: string; 81 | }; 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/docs/Features/stateful-loader.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar-position: 6 3 | --- 4 | 5 | # Stateful loaders 6 | 7 | Since a `Loader` contains a hook, that hook can contain state. 8 | 9 | ```typescript 10 | const loader = createLoader({ 11 | useQueries: () => { 12 | const [name, setName] = useState("charizard"); 13 | const pokemon = useGetPokemon(name); 14 | return { 15 | queries: { 16 | pokemon, 17 | }, 18 | }; 19 | }, 20 | }); 21 | ``` 22 | 23 | You can then control this state, by sending the handlers through `payload`: 24 | 25 | ```typescript {10} 26 | const componentLoader = createLoader({ 27 | useQueries: () => { 28 | const [name, setName] = useState("charizard"); 29 | const debouncedName = useDebounce(name, 200); 30 | const pokemon = useGetPokemon(debouncedName); 31 | return { 32 | queries: { 33 | pokemon, 34 | }, 35 | payload: { name, setName }, 36 | }; 37 | }, 38 | }); 39 | 40 | const Component = withLoader((props, loader) => { 41 | const onChange = (e) => loader.payload.setName(e.target.value); 42 | // ... 43 | }, componentLoader); 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/docs/Features/transforming.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Transform 6 | 7 | You can transform the queries to any format you'd like. 8 | 9 | ## Example 10 | 11 | ```ts 12 | const notTransformed = createLoader({ 13 | useQueries: () => ({ 14 | queries: { pokemons: useGetPokemonsQuery() }, 15 | }), 16 | }); 17 | 18 | type NotTransformedData = InferLoaderData; 19 | // { queries: { pokemons: UseQueryResult } } 20 | 21 | const transformed = createLoader({ 22 | useQueries: () => ({ 23 | queries: { pokemons: useGetPokemonsQuery() }, 24 | }), 25 | transform: (loader) => ({ 26 | results: loader.queries.pokemons.data, 27 | query: loader.queries.pokemons, 28 | }), 29 | }); 30 | 31 | type TransformedData = InferLoaderData; 32 | // { results: Pokemon[]; query: UseQueryResult; } 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/docs/Migrations/v0.x.md: -------------------------------------------------------------------------------- 1 | # Migrating to `1.0.0` 2 | 3 | If you are using `@ryfylke-react/rtk-query-loader@0.3.51` or earlier, these docs will help you migrate your existing codebase over to `1.0.0`. 4 | 5 | ## Input format for data 6 | 7 | Previously, queries have been passed using two arguments: 8 | 9 | - **`queries`** 10 | - **`deferredQueries`** 11 | 12 | These two functions used to return a `readonly UseQueryResult[]`. 13 | 14 | In version `1.0.0`, these arguments have now been joined into one, called `useQueries`. 15 | 16 | ```typescript {3-10,15-23} 17 | // Previously 18 | const loader = createLoader({ 19 | queries: (arg: string) => { 20 | const pokemonQuery = useGetPokemon(arg); 21 | return [pokemonQuery] as const; 22 | }, 23 | deferredQueries: () => { 24 | const otherQuery = useOtherQuery(); 25 | return [otherQuery] as const; 26 | }, 27 | }); 28 | 29 | // In version 1: 30 | const loader = createLoader({ 31 | useQueries: (arg: string) => { 32 | const pokemonQuery = useGetPokemon(arg); 33 | const otherQuery = useOtherQuery(); 34 | 35 | return { 36 | queries: { pokemonQuery }, 37 | deferredQueries: { otherQuery }, 38 | }; 39 | }, 40 | }); 41 | ``` 42 | 43 | :::info 44 | Previously, you _had_ to use `transform` as well to expose the deferred queries to your consumer. This is no longer required. 45 | ::: 46 | 47 | ## Output format for data 48 | 49 | This also means that the output format of the loader has changed. Typescript will help you out here, but for the most part **what you send in is what you get out**. 50 | 51 | ```typescript 52 | const loader = createLoader({ 53 | useQueries: () => ({ 54 | queries: { 55 | pokemons: useGetPokemons(), 56 | }, 57 | }), 58 | }); 59 | 60 | type LoaderData = InferLoaderData; 61 | // { 62 | // queries: { 63 | // pokemons: UseQueryResult 64 | // } 65 | // } 66 | ``` 67 | 68 | This output format change will affect your consumer components, as well as the (now single) argument passed to `transform`. 69 | 70 | You can still of course optionally [transform](../Features/transforming.md) the output. 71 | 72 | :::tip You should know 73 | This I/O interface change is the **only breaking change** from the previous versions. 74 | ::: 75 | 76 | :::note 77 | Through rewriting the tests and lots of local testing, I can assure you that this refactor should be relatively straight forward and easy to do. If you fix the loaders first by changing and `queries` and `deferredQueries` to now use `useQueries`, you can let Typescript help you out in the consumers afterwards. 78 | ::: 79 | 80 | ## New feature: `payload` 81 | 82 | You can now send _any_ data you want through the loader. This is useful for when your loader contains a lot of logic, or if you want to pass some handlers down to the consumer to change the arguments of your queries. 83 | 84 | ```typescript 85 | const loader = createLoader({ 86 | useQueries: () => { 87 | const [pokemonName, setPokemonName] = useState("charizard"); 88 | const pokemon = useGetPokemon(pokemonName); 89 | 90 | return { 91 | queries: { 92 | pokemon, 93 | }, 94 | payload: { 95 | changePokemon: setPokemonName, 96 | }, 97 | }; 98 | }, 99 | }); 100 | 101 | const Consumer = withLoader((props, data) => { 102 | const { payload, queries } = data; 103 | // ... 104 | }, loader); 105 | ``` 106 | 107 | Previously, if you wanted to do something like this, you had to use [useCreateQuery](../Reference/use-create-query.md), or a wrapper component. If you did, you can now refactor your code to use `payload` instead, which should be a lot more clean and flexible. 108 | 109 | ## Change: Extend transform 110 | 111 | Previously, `transform` and `queries` were linked when extending. Meaning you had to either extend both, just `queries` or neither of these. You could not supply _just_ a `transform`. This is no longer the case, and you are free to extend with _just_ transform as well. 112 | 113 | ## Reporting bugs 114 | 115 | If you find any bugs or issues with this new update, I strongly encourage you to [file a bug report](https://github.com/ryfylke-react-as/rtk-query-loader/issues/new?assignees=&labels=&template=bug_report.md&title=) on the Github repo. 116 | 117 | I hope you are happy with the changes coming in version `1`. They have been thoroughly thought out and brewed on for a couple of months. Although it's never fun to push a breaking change, I hope you agree that these changes add a lot more flexibility to the loaders, and less confusion around I/O format. I'm personally very glad that I'm no longer required to rembember to use `as const` to ensure I get the correct types in my loader. 118 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "collapsed": false 3 | } 4 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/accept-arguments.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # 4. Arguments for your loader 6 | 7 | If you want the loader to take an argument, all you have to do is to add a `queriesArg`: 8 | 9 | ```tsx {5-7,10-12} 10 | import { ConsumerProps } from "@ryfylke-react/rtk-query-loader"; 11 | 12 | // This means that any component that has props that extend this 13 | // type can consume the loader using `withLoader` 14 | type UserRouteLoaderProps = ConsumerProps<{ 15 | userId: string; 16 | }>; 17 | 18 | export const userRouteLoader = baseLoader.extend({ 19 | queriesArg: (props: UserRouteLoaderProps) => props.userId, 20 | // type is now inferred from queriesArg return 21 | useQueries: (userId) => { 22 | const user = useGetUserQuery(userId); 23 | const posts = useGetPostsByUser(userId); 24 | 25 | return { 26 | queries: { 27 | user, 28 | posts, 29 | }, 30 | }; 31 | }, 32 | }); 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/add-queries.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # 3. Add queries 6 | 7 | You can now start to add queries to your extended loaders. 8 | 9 | The `useQueries` argument of [createLoader](/Reference/create-loader) is a _hook_, which means that [the rules of hooks](https://reactjs.org/docs/hooks-rules.html) apply. This gives you the super-power of utilizing other hooks inside of your loader. 10 | 11 | ```tsx title="/src/loaders/userRouteLoader.tsx" {6-15} 12 | import { baseLoader } from "./baseLoader"; 13 | // ... 14 | 15 | export const userRouteLoader = baseLoader.extend({ 16 | useQueries: () => { 17 | const { userId } = useParams(); 18 | const user = useGetUserQuery(userId); 19 | const posts = useGetPostsByUser(userId); 20 | 21 | return { 22 | queries: { 23 | user, 24 | posts, 25 | }, 26 | }; 27 | }, 28 | }); 29 | ``` 30 | 31 | You can add as many queries as you'd like to `Response.queries`, and they will all aggregate to a common loading state. 32 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/base-loader.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # 1. Create a base loader 6 | 7 | A `Loader` can control the loading, fetch and error state views for consumer components. 8 | 9 | :::tip 10 | Use a base loader to create sensible "fallback"/"default"-states for consumer components. 11 | ::: 12 | 13 | ```tsx title="/src/loaders/baseLoader.tsx" {7-20} 14 | import { 15 | createLoader, 16 | withLoader, 17 | } from "@ryfylke-react/rtk-query-loader"; 18 | import { Loading, GenericErrorView } from "../components/common"; 19 | 20 | export const baseLoader = createLoader({ 21 | onLoading: () => , 22 | onError: (props, error) => , 23 | }); 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/consume-loader.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | import Tabs from "@theme/Tabs"; 6 | import TabItem from "@theme/TabItem"; 7 | 8 | # 5. Consume the loader 9 | 10 | ## Picking the right method 11 | 12 | We recommend consuming the loader with either `withLoader` or `AwaitLoader`. 13 | 14 | - Use `withLoader` if you want to use the loader to affect the whole component. 15 | - Use `AwaitLoader` if you want to use the loader in a more conditional way, or if you only want a small section of your component to start loading data, after the rest of the component has rendered. 16 | 17 | You could also use `useLoader`, which is the hook that `withLoader` and `AwaitLoader` use under the hood. This will simply return the aggregated queries of the loader, and is useful if you want to use the data, but **not the loading and error states of the loader**. 18 | 19 | Chart showing differences between AwaitLoader and withLoader 24 | 25 | ## Examples 26 | 27 | Select a tab below to see an example of how you would consume the loader using that specific method. 28 | 29 | 30 | 31 | 32 | A convenient wrapper that ensures that the component is only rendered when it has data. 33 | 34 | :::info 35 | This is the recommended and optimal way to consume the loaders. 36 | ::: 37 | 38 | ```tsx {8-26} 39 | import { withLoader } from "@ryfylke-react/rtk-query-loader"; 40 | import { userRouteLoader } from "../loaders/baseLoader"; 41 | 42 | type Props = { 43 | /* ... */ 44 | }; 45 | 46 | export const UserRoute = withLoader((props: Props, loader) => { 47 | // `queries` is typed correctly, and ensured to have loaded. 48 | const { 49 | user, 50 | posts 51 | } = loader.queries; 52 | 53 | return ( 54 |
55 |
56 |

{user.data.name}

57 | {user.isFetching || posts.isFetching ? () : null} 58 |
59 |
60 | {posts.data.map((post) => (...))} 61 |
62 |
63 | ); 64 | }, userRouteLoader); 65 | ``` 66 | 67 | 77 | 78 |
79 | 80 | 81 | 82 | > **New in version 1.0.4** 83 | 84 | If you prefer not using higher order components, or want to use the loader in a more conditional way, you can use the <`AwaitLoader` /> component. 85 | 86 | ```tsx {6-18} 87 | import { AwaitLoader } from "@ryfylke-react/rtk-query-loader"; 88 | import { userRouteLoader } from "./baseLoader"; 89 | 90 | const UserRoute = () => { 91 | return ( 92 | ( 95 |
96 |
97 |

{queries.user.data.name}

98 |
99 |
100 | {queries.posts.data.map((post) => (...))} 101 |
102 |
103 | )} 104 | /> 105 | ); 106 | }; 107 | ``` 108 | 109 | 119 | 120 |
121 | 122 | 123 | Every `Loader` contains an actual hook that you can call to run all the queries and aggregate their statuses as if they were just one joined query. 124 | 125 | This is convenient if you want to simply use the data, but not the loading and error states of the loader. 126 | 127 | ```tsx {3,9} 128 | import { userRouteLoader } from "./baseLoader"; 129 | 130 | const useLoaderData = userRouteLoader.useLoader; 131 | 132 | type Props = { 133 | /* ... */ 134 | }; 135 | const UserRoute = (props: Props) => { 136 | const loaderQuery = useLoaderData(); 137 | 138 | if (loaderQuery.isLoading) { 139 | // ... 140 | } 141 | 142 | if (loaderQuery.isError) { 143 | // ... 144 | } 145 | // ... 146 | }; 147 | ``` 148 | 149 | 153 | 154 |
155 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/extend-loader.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # 2. Extend the base loader 6 | 7 | Extend from the base loader so that you inherit the sensible defaults. You can overwrite these at any point. 8 | 9 | ```tsx title="/src/loaders/userRouteLoader.tsx" {3-11} 10 | import { baseLoader } from "./baseLoader"; 11 | 12 | export const userRouteLoader = baseLoader.extend({}); 13 | ``` 14 | 15 | You can pass any argument from [`createLoader`](/Reference/create-loader) into [`Loader.extend`](/Features/extending). 16 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/extend-loader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/docs/Quick Guide/extend-loader.png -------------------------------------------------------------------------------- /docs/docs/Quick Guide/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | import DocCardList from "@theme/DocCardList"; 6 | 7 | # Quick guide 8 | 9 | The following guide will walk you through some of our recommended best practises for making the most out of RTK Query Loader. 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/docs/Quick Guide/queriesArg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/docs/Quick Guide/queriesArg.png -------------------------------------------------------------------------------- /docs/docs/Quick Guide/rtk-query-loader-chart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/docs/Quick Guide/rtk-query-loader-chart.png -------------------------------------------------------------------------------- /docs/docs/Reference/aggregate-to-query.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 7 3 | --- 4 | 5 | # `aggregateToQuery()` 6 | 7 | Aggregates a set of `UseQueryResult` into a single query. 8 | 9 | ```typescript 10 | import { 11 | aggregateToQuery, 12 | UseQueryResult, 13 | } from "@ryfylke-react/rtk-query-loader"; 14 | 15 | const queries = [ 16 | useQueryOne(), 17 | useQueryTwo(), 18 | useQueryThree(), 19 | ] as const; 20 | 21 | const query: UseQueryResult = { 22 | ...aggregateToQuery(queries), 23 | data: queries.map((query) => query.data), 24 | }; 25 | ``` 26 | 27 | :::caution 28 | `aggregateToQuery` does not add any `data` to the resulting query. You will have to do that manually afterwards. 29 | ::: 30 | -------------------------------------------------------------------------------- /docs/docs/Reference/awaitloader.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # `` 6 | 7 | > **New in version 1.0.4** 8 | 9 | AwaitLoader is a component that consumes a loader and then renders the proper load/fetch/error states, and `props.render` on success. 10 | 11 | ## Arguments 12 | 13 | - **`loader`** - The loader that contains the queries you want to await. 14 | - **`render`** - A function that recieves the loader data and should return a ReactElement. 15 | - **`args`** - Takes in the expected props for the given loader. 16 | 17 | ```tsx 18 | const loader = createLoader({ 19 | queriesArg: (props: { name: string }) => props.name, 20 | useQueries: (name) => ({ 21 | queries: { 22 | pokemon: useGetPokemonQuery(name), 23 | }, 24 | }), 25 | }); 26 | 27 | const App = () => { 28 | return ( 29 |
30 | ( 34 |
{JSON.stringify(data.queries.pokemon.data)}
35 | )} 36 | /> 37 | ( 41 |
{JSON.stringify(data.queries.pokemon.data)}
42 | )} 43 | /> 44 |
45 | ); 46 | }; 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/docs/Reference/consumer-props.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # `ConsumerProps<>` 6 | 7 | Helper for typing your expected consumer props for the loader. 8 | 9 | ```typescript {1-3,6} 10 | type PokemonLoaderProps = ConsumerProps<{ 11 | name: string; 12 | }>; 13 | 14 | const pokemonLoader = createLoader({ 15 | queriesArg: (props: PokemonLoaderProps) => props.name, 16 | useQueries: (name) => ({ 17 | queries: { pokemon: useGetPokemon(name) }, 18 | }), 19 | }); 20 | 21 | // pokemonLoader can now we used by any component 22 | // that accepts a prop `name` of type `string`. 23 | 24 | type PokemonComponentProps = { 25 | name: string; 26 | size: number; 27 | }; // ✅ 28 | 29 | type OtherPokemonComponentProps = { 30 | name: string; 31 | }; // ✅ 32 | 33 | type IncompatibleProps = { 34 | pokemonName: string; 35 | }; // ❌ 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/docs/Reference/create-loader.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # `createLoader()` 6 | 7 | Creates a `Loader`. 8 | 9 | ## Arguments 10 | 11 | All arguments are optional. 12 | 13 | - **`useQueries`** - A hook that runs the queries and returns them. It can take an argument, which can be transformed from the consumer props through `queriesArg` 14 | ```typescript 15 | useQueries: (arg?: Arg) => { 16 | queries?: Record; 17 | deferredQueries?: Record; 18 | payload?: any; 19 | } 20 | ``` 21 | - **`queriesArg`** - A function that transforms the consumer props into the argument for `useQueries` - required if your loader should be able to take arguments from props. 22 | ```typescript 23 | queriesArg: (props: Props) => Arg; 24 | ``` 25 | - **`transform`** - A function that lets you transform the shape of the output data. 26 | ```typescript 27 | transform: (data: Data) => TransformedData; 28 | ``` 29 | - **`onLoading`** - A function that determines what to render while the component is going through it's initial load phase. 30 | ```typescript 31 | onLoading: (props: Props) => ReactElement; 32 | ``` 33 | - **`onError`** - A function that determines what to render while the component is going through it's initial load phase. 34 | ```typescript 35 | onError: (props: Props, error: unknown) => ReactElement; 36 | ``` 37 | - **`whileFetching`** - An object that lets you append &/ prepend elements to your component while it is fetching. 38 | ```typescript 39 | whileFetching: { 40 | prepend?: (props: Props, data?: Data) => ReactElement; 41 | append?: (props: Props, data?: Data) => ReactElement; 42 | }; 43 | ``` 44 | - **`config`** - An object that lets you configure the behavior of the loader. 45 | ```typescript 46 | config: { 47 | defer? { 48 | shouldThrowError?: boolean; 49 | }; 50 | loaderComponent?: Component; 51 | } 52 | ``` 53 | 54 | ## Return 55 | 56 | `createLoader` returns a `Loader`. You can use this loader with [withLoader](with-loader.md), or you can call the `.extend` method to produce a new loader using the original loader as a base. `.extend` takes the same set of arguments as `createLoader`. 57 | 58 | ## Example usage 59 | 60 | ```typescript title="example.ts" 61 | type ConsumerProps = Record & { 62 | userId: string; 63 | }; 64 | 65 | const loader = createLoader({ 66 | queriesArg: (props: ConsumerProps) => props.userId, 67 | useQueries: (userId) => { 68 | return { 69 | queries: { 70 | user: useGetUser(userId), 71 | }, 72 | deferredQueries: { 73 | relations: useGetUserRelations(userId), 74 | }, 75 | payload: { 76 | // Lets you send any static data to your consumer 77 | static: "data", 78 | }, 79 | }; 80 | }, 81 | transform: (loaderData) => ({ 82 | ...loaderData, 83 | foo: "bar", 84 | }), 85 | onLoading: (props) => , 86 | onError: (props, error) => , 87 | whileFetching: { 88 | prepend: (props) => , 89 | }, 90 | }); 91 | ``` 92 | 93 | :::caution Using version `0.3.51` or earlier? 94 | Please refer to the [**migration guide**](../Migrations/v0.x). 95 | ::: 96 | -------------------------------------------------------------------------------- /docs/docs/Reference/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | sidebar_collapsed: false 4 | --- 5 | 6 | import DocCardList from "@theme/DocCardList"; 7 | 8 | # Reference 9 | 10 | Describes the different exports and their related types. 11 | 12 | All the types can be found [here](https://github.com/ryfylke-react-as/rtk-query-loader/blob/main/src/types.ts). 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/docs/Reference/infer-loader-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | --- 4 | 5 | # `InferLoaderData<>` 6 | 7 | Returns the return type of a given `Loader`: 8 | 9 | ```typescript 10 | import { 11 | createLoader, 12 | InferLoaderData, 13 | } from "@ryfylke-react/rtk-query-loader"; 14 | 15 | const loader = createLoader({...}); 16 | 17 | type LoaderData = InferLoaderData; 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/docs/Reference/use-create-query.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # `useCreateQuery()` 6 | 7 | Lets you use any function that returns a promise to load your component. 8 | 9 | ## Arguments 10 | 11 | `useCreateQuery` takes two arguments: 12 | 13 | - The first argument is a function that returns a promise. 14 | - The second argument is a dependency array. Whenever a value in this array changes, the query is re-run. 15 | 16 | ## Example usage 17 | 18 | ```typescript 19 | import { 20 | createLoader, 21 | useCreateQuery, 22 | } from "@ryfylke-react/rtk-query-loader"; 23 | 24 | const getUser = async (userId: string) => { 25 | const res = await fetch(`users/${userId}`); 26 | const json = await res.json(); 27 | return json as SomeDataType; 28 | }; 29 | 30 | const loader = createLoader({ 31 | useQueries: (userId: string) => { 32 | const query = useCreateQuery( 33 | async () => await getUser(userId), 34 | [userId] 35 | ); 36 | 37 | return { 38 | queries: { 39 | query, 40 | }, 41 | }; 42 | }, 43 | }); 44 | ``` 45 | 46 | :::caution 47 | You lose some great features from RTK query when using `useCreateQuery`, like global query invalidation (beyond the dependency array), request cancellation and caching. 48 | ::: 49 | -------------------------------------------------------------------------------- /docs/docs/Reference/with-loader.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # `withLoader()` 6 | 7 | Wraps a component and provides it with a given `Loader`'s data. Renders the appropriate load/fetch/error states of the loader, and finally the given component when data is loaded successfully. 8 | 9 | ## Arguments 10 | 11 | `withLoader` takes two arguments 12 | 13 | - The first argument is a component, but with an extra parameter for the loaded data. 14 | - The second argument is a `Loader` (returned from [`createLoader`](./create-loader.md)) 15 | 16 | ```typescript 17 | type Arg1 = (props: P, loaderData: R) => ReactElement; 18 | type Arg2 = Loader; 19 | ``` 20 | 21 | ## Example usage 22 | 23 | ```tsx 24 | const pokemonLoader = createLoader({ 25 | useQueries: () => ({ 26 | queries: { pokemon: useGetPokemon() }, 27 | }), 28 | }); 29 | 30 | const Pokemon = withLoader((props, { queries }) => { 31 | return
{queries.pokemon.data.name}
; 32 | }, pokemonLoader); 33 | ``` 34 | 35 | Here is another example, where the arguments are all unwrapped: 36 | 37 | ```tsx 38 | const pokemonLoader = createLoader({ 39 | useQueries: () => ({ 40 | queries: { pokemon: useGetPokemon() }, 41 | }), 42 | }); 43 | 44 | type PokemonData = InferLoaderData; 45 | 46 | const LoadedComponent = (props, loaderData: PokemonData) => { 47 | return
{loaderData.queries.pokemon.data.name}
; 48 | }; 49 | 50 | const Pokemon = withLoader(LoadedComponent, pokemonLoader); 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/docs/examples.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | import TOCInline from "@theme/TOCInline"; 6 | 7 | # Examples 8 | 9 | This page contains a collection of examples that cover typical use cases for the library. 10 | 11 | 12 | 13 | ## React Router (route loader) 14 | 15 | By utilizing `useParams` from `react-router-dom` we can create a loader that will load data based on the route parameters. 16 | 17 | ```tsx 18 | import { 19 | withLoader, 20 | createLoader, 21 | } from "@ryfylke-react/rtk-query-loader"; 22 | import { useParams, Redirect } from "react-router-dom"; 23 | // ... 24 | 25 | const userRouteLoader = createLoader({ 26 | useQueries: () => { 27 | const { userId } = useParams(); 28 | const userQuery = useGetUserQuery(userId, { 29 | skip: userId ? false : true, 30 | }); 31 | 32 | return { 33 | queries: { 34 | userQuery, 35 | }, 36 | }; 37 | }, 38 | onLoading: (props) => { 39 | const { userId } = useParams(); 40 | if (!userId) { 41 | return ; 42 | } 43 | return ; 44 | }, 45 | onError: (props, error) => , 46 | }); 47 | ``` 48 | 49 | ## Reusable loader 50 | 51 | We can also create a reusable loader that can be used with multiple components. 52 | 53 | ```tsx 54 | import { 55 | withLoader, 56 | createLoader, 57 | ConsumerProps, 58 | } from "@ryfylke-react/rtk-query-loader"; 59 | 60 | type UserLoaderProps = { 61 | userId: string; 62 | }; 63 | 64 | const userLoader = createLoader({ 65 | queriesArg: (props: UserLoaderProps) => props.userId, 66 | useQueries: (userId) => { 67 | const userQuery = useGetUserQuery(userId); 68 | 69 | return { 70 | queries: { 71 | userQuery, 72 | }, 73 | }; 74 | }, 75 | onLoading: (props) => , 76 | onError: (props, error) => , 77 | }); 78 | ``` 79 | 80 | You can now use the `userLoader` in any component whos props extend `UserLoaderProps`. 81 | 82 | ### Consumer 1 83 | 84 | ```tsx title="UserProfile.tsx" 85 | import { userLoader } from "../loaders"; 86 | 87 | type UserProfileProps = { 88 | userId: string; 89 | // ... other props 90 | }; 91 | 92 | export const UserProfile = withLoader( 93 | (props: UserProfileProps, data) => { 94 | return <>...; 95 | }, 96 | userLoader 97 | ); 98 | ``` 99 | 100 | ### Consumer 2 101 | 102 | ```tsx title="UserProfile.tsx" 103 | import { userLoader } from "../loaders"; 104 | 105 | type InlineUserDetailsProps = { 106 | userId: string; 107 | dir: "row" | "column"; 108 | // ... other props 109 | }; 110 | 111 | export const InlineUserDetails = withLoader( 112 | (props: InlineUserDetailsProps, data) => { 113 | return <>...; 114 | }, 115 | userLoader 116 | ); 117 | ``` 118 | 119 | ## Stateful loader 120 | 121 | You can also create loaders that contain state. 122 | 123 | ```tsx 124 | const loader = createLoader({ 125 | useQueries: () => { 126 | const [name, setName] = useState("charizard"); 127 | const debouncedName = useDebounce(name, 200); 128 | const pokemon = useGetPokemon(debouncedName); 129 | return { 130 | queries: { 131 | pokemon, 132 | }, 133 | payload: { 134 | name, 135 | setName, 136 | }, 137 | }; 138 | }, 139 | }); 140 | 141 | const Consumer = withLoader((props, data) => { 142 | return ( 143 |
144 | data.payload.setName(e.target.value)} 147 | /> 148 |
AP: {data.queries.pokemon.data.ability_power}
149 |
150 | ); 151 | }, loader); 152 | ``` 153 | -------------------------------------------------------------------------------- /docs/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | slug: / 4 | title: Introduction 5 | --- 6 | 7 | # RTK Query Loader 8 | 9 | > Create _loaders_ for your React components. 10 | 11 | 🔗 **Extendable** 12 | 💫 **Transformable** 13 | ♻️ **Reusable** 14 | ✅ **Properly typed** 15 | 16 | You write your components, as if the data has already loaded. 17 | 18 | ## Install 19 | 20 | ```shell 21 | npm i @ryfylke-react/rtk-query-loader 22 | ``` 23 | 24 | ## Example 25 | 26 | ```tsx 27 | type Props = { 28 | name: string; 29 | }; 30 | 31 | // Creating a loader 32 | const loader = createLoader({ 33 | queriesArg: (props: Props) => props.name, 34 | useQueries: (name) => ({ 35 | queries: { 36 | pokemon: useGetPokemonQuery(name), 37 | }, 38 | }), 39 | }); 40 | 41 | // Consuming the loader 42 | const Pokemon = withLoader((props, { queries }) => { 43 | return ( 44 |
45 |

{queries.pokemon.data.name}

46 |
{/* ... */}
47 |
48 | ); 49 | }, loader); 50 | ``` 51 | 52 | ## Playground 53 | 54 | 61 | -------------------------------------------------------------------------------- /docs/docs/problem-solve.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # What problem does this solve? 6 | 7 | **Handling the loading and error state of components that depend on external data can be tedious, 8 | especially when you are managing multiple queries.** 9 | 10 | ### Example 11 | 12 | This component requires some data. There are 26 lines of code that are **just** concerned with ensuring that the data is present before rendering the _actual_ component. 13 | 14 | ```typescript {2-28} 15 | const Component = () => { 16 | const pokemonQuery = useGetPokemon("charizard"); 17 | const userQuery = useGetCurrentUser(); 18 | const userStatsQuery = useGetUserStats(); 19 | 20 | if ( 21 | pokemonQuery.isLoading || 22 | userQuery.isLoading || 23 | userStatsQuery.isLoading 24 | ) { 25 | return ; 26 | } 27 | 28 | if ( 29 | pokemonQuery.isError || 30 | userQuery.isError || 31 | userStatsQuery.isError 32 | ) { 33 | return ( 34 | 41 | ); 42 | } 43 | // Finally... 44 | return ( 45 | 50 | ); 51 | }; 52 | ``` 53 | 54 | RTK Query Loader lets you **move all of this logic out of the component**, and also make it _reusable_ and _composable_, so that other components can reuse that loader and have access to the same data. 55 | 56 | - [x] Isolate the data-loading code away from the presentational components 57 | - [x] Increase type certainty 58 | - 🔥 Way less optional chaining in your components 59 | - 🔥 You write the components as if the data is already present 60 | - [x] Composability 61 | - ♻️ Extend existing loaders 62 | - ♻️ Overwrite only select properties 63 | - [x] You're still fully in control 64 | - 🛠️ Loading/error states 65 | - 🛠️ Custom loader-component 66 | - 🛠️ Configure the behavior of your loaders 67 | 68 | ## Terminology 69 | 70 | Terminology explaination 74 | 75 | ## Alternatives 76 | 77 | If you are using a Suspense-enabled framework, or any form of server-side rendering that can feed your components with data, then that would be a more optimal approach. [Remix](https://remix.run/docs/en/1.14.3/route/loader) has the concept of loaders built in, and NextJS is suspense enabled. 78 | 79 | If you are, however, building an SPA, or not using Next or Remix, then this package can be a great way for you gain the concept of loaders without moving to server-side rendering. 80 | -------------------------------------------------------------------------------- /docs/docs/terminology.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/docs/terminology.png -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require("prism-react-renderer/themes/github"); 5 | const darkCodeTheme = require("prism-react-renderer/themes/dracula"); 6 | require("dotenv").config(); 7 | 8 | /** @type {import('@docusaurus/types').Config} */ 9 | const config = { 10 | title: "RTK Query Loader", 11 | tagline: "Component loaders for RTK Query", 12 | favicon: "img/logo.png", 13 | // Algolia search config 14 | // themes: ["@docusaurus/theme-search-algolia"], 15 | // Set the production url of your site here 16 | url: "https://rtk-query-loader.ryfylke.dev", 17 | // Set the // pathname under which your site is served 18 | // For GitHub pages deployment, it is often '//' 19 | baseUrl: "/", 20 | 21 | // GitHub pages deployment config. 22 | // If you aren't using GitHub pages, you don't need these. 23 | organizationName: "ryfylke-react-as", // Usually your GitHub org/user name. 24 | projectName: "rtk-query-loader", // Usually your repo name. 25 | 26 | onBrokenLinks: "throw", 27 | onBrokenMarkdownLinks: "warn", 28 | 29 | // Even if you don't use internalization, you can use this field to set useful 30 | // metadata like html lang. For example, if your site is Chinese, you may want 31 | // to replace "en" with "zh-Hans". 32 | i18n: { 33 | defaultLocale: "en", 34 | locales: ["en"], 35 | }, 36 | 37 | presets: [ 38 | [ 39 | "classic", 40 | /** @type {import('@docusaurus/preset-classic').Options} */ 41 | ({ 42 | docs: { 43 | sidebarPath: require.resolve("./sidebars.js"), 44 | editUrl: 45 | "https://github.com/ryfylke-react-as/rtk-query-loader/tree/main/docs/", 46 | routeBasePath: "/", 47 | }, 48 | blog: false, 49 | theme: { 50 | customCss: require.resolve("./src/css/custom.css"), 51 | }, 52 | }), 53 | ], 54 | ], 55 | 56 | themeConfig: 57 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 58 | ({ 59 | algolia: { 60 | apiKey: process.env.ALGOLIA_API_KEY, 61 | appId: "1NCL1FSCF8", 62 | indexName: "rtk-query-loader", 63 | }, 64 | // Replace with your project's social card 65 | image: "img/ryfrea-social-card.png", 66 | navbar: { 67 | title: "RTK Query Loader", 68 | logo: { 69 | alt: "Ryfylke React Logo", 70 | src: "img/logo.png", 71 | }, 72 | items: [ 73 | { 74 | href: "https://github.com/ryfylke-react-as/rtk-query-loader/releases", 75 | label: "Changelog", 76 | position: "right", 77 | }, 78 | { 79 | href: "https://codesandbox.io/s/rtk-query-loader-1-0-0-demo-forked-du3936?file=/src/loaders/pokemonLoader.tsx", 80 | label: "Demo", 81 | position: "right", 82 | }, 83 | { 84 | href: "https://www.npmjs.com/package/@ryfylke-react/rtk-query-loader", 85 | label: "NPM", 86 | position: "right", 87 | className: "icon-npm", 88 | }, 89 | { 90 | href: "https://github.com/ryfylke-react-as/rtk-query-loader", 91 | label: "GitHub", 92 | position: "right", 93 | }, 94 | { 95 | href: "https://github.com/ryfylke-react-as/rtk-query-loader", 96 | label: "GitHub", 97 | position: "right", 98 | className: "icon-github", 99 | }, 100 | ], 101 | }, 102 | footer: { 103 | style: "dark", 104 | links: [ 105 | { 106 | title: "Docs", 107 | items: [ 108 | { 109 | label: "Introduction", 110 | to: "/", 111 | }, 112 | { 113 | label: "Quick guide", 114 | to: "/Quick Guide", 115 | }, 116 | { 117 | label: "Examples", 118 | to: "/examples", 119 | }, 120 | { 121 | label: "Features", 122 | to: "/Features", 123 | }, 124 | { 125 | label: "Reference", 126 | to: "/Reference", 127 | }, 128 | ], 129 | }, 130 | { 131 | title: "Resources", 132 | items: [ 133 | { 134 | label: "GitHub", 135 | href: "https://github.com/ryfylke-react-as/rtk-query-loader", 136 | }, 137 | { 138 | label: "NPM", 139 | href: "https://www.npmjs.com/package/@ryfylke-react/rtk-query-loader", 140 | }, 141 | { 142 | label: "Demo (CodeSandbox)", 143 | href: "https://codesandbox.io/s/rtk-query-loader-1-0-0-demo-forked-du3936?file=/src/loaders/pokemonLoader.tsx", 144 | }, 145 | ], 146 | }, 147 | { 148 | title: "More from Ryfylke React", 149 | items: [ 150 | { 151 | label: "Ryfylke React Toast", 152 | href: "https://toast.ryfylke.dev", 153 | }, 154 | { 155 | label: "More", 156 | href: "https://open-source.ryfylke.dev", 157 | }, 158 | ], 159 | }, 160 | ], 161 | copyright: `
Open source / GPL-3.0 License
Made with ❤️ by Ryfylke React`, 162 | }, 163 | prism: { 164 | theme: lightCodeTheme, 165 | darkTheme: darkCodeTheme, 166 | }, 167 | }), 168 | }; 169 | 170 | module.exports = config; 171 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "^2.4.0", 19 | "@docusaurus/preset-classic": "^2.4.0", 20 | "@docusaurus/theme-search-algolia": "^2.4.0", 21 | "@mdx-js/react": "^1.6.22", 22 | "@reduxjs/toolkit": "^1.9.3", 23 | "clsx": "^1.2.1", 24 | "prism-react-renderer": "^1.3.5", 25 | "react": "^17.0.2", 26 | "react-dom": "^17.0.2", 27 | "react-redux": "^8.0.5" 28 | }, 29 | "devDependencies": { 30 | "@docusaurus/module-type-aliases": "2.3.1", 31 | "@tsconfig/docusaurus": "^1.0.5", 32 | "dotenv": "^16.0.3", 33 | "typescript": "^4.7.4" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.5%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "engines": { 48 | "node": ">=16.14" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from './styles.module.css'; 4 | 5 | type FeatureItem = { 6 | title: string; 7 | Svg: React.ComponentType>; 8 | description: JSX.Element; 9 | }; 10 | 11 | const FeatureList: FeatureItem[] = [ 12 | { 13 | title: 'Easy to Use', 14 | Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, 15 | description: ( 16 | <> 17 | Docusaurus was designed from the ground up to be easily installed and 18 | used to get your website up and running quickly. 19 | 20 | ), 21 | }, 22 | { 23 | title: 'Focus on What Matters', 24 | Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, 25 | description: ( 26 | <> 27 | Docusaurus lets you focus on your docs, and we'll do the chores. Go 28 | ahead and move your docs into the docs directory. 29 | 30 | ), 31 | }, 32 | { 33 | title: 'Powered by React', 34 | Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, 35 | description: ( 36 | <> 37 | Extend or customize your website layout by reusing React. Docusaurus can 38 | be extended while reusing the same header and footer. 39 | 40 | ), 41 | }, 42 | ]; 43 | 44 | function Feature({title, Svg, description}: FeatureItem) { 45 | return ( 46 |
47 |
48 | 49 |
50 |
51 |

{title}

52 |

{description}

53 |
54 |
55 | ); 56 | } 57 | 58 | export default function HomepageFeatures(): JSX.Element { 59 | return ( 60 |
61 |
62 |
63 | {FeatureList.map((props, idx) => ( 64 | 65 | ))} 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures/styles.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #2e8555; 10 | --ifm-color-primary-dark: #29784c; 11 | --ifm-color-primary-darker: #277148; 12 | --ifm-color-primary-darkest: #205d3b; 13 | --ifm-color-primary-light: #33925d; 14 | --ifm-color-primary-lighter: #359962; 15 | --ifm-color-primary-lightest: #3cad6e; 16 | --ifm-code-font-size: 95%; 17 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); 18 | } 19 | 20 | /* For readability concerns, you should choose a lighter palette in dark mode. */ 21 | [data-theme="dark"] { 22 | --ifm-color-primary: #25c2a0; 23 | --ifm-color-primary-dark: #21af90; 24 | --ifm-color-primary-darker: #1fa588; 25 | --ifm-color-primary-darkest: #1a8870; 26 | --ifm-color-primary-light: #29d5b0; 27 | --ifm-color-primary-lighter: #32d8b4; 28 | --ifm-color-primary-lightest: #4fddbf; 29 | --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); 30 | } 31 | 32 | /* .icon-npm { 33 | display: flex; 34 | align-items: center; 35 | gap: 0.5rem; 36 | font-size: 0px; 37 | } 38 | .icon-npm svg { 39 | display: none; 40 | } 41 | .icon-npm::after { 42 | background-color: rgb(55, 55, 55); 43 | background-image: url("https://img.shields.io/npm/v/@ryfylke-react/rtk-query-loader?color=gray&style=flat-square"); 44 | width: 80px; 45 | height: 20px; 46 | content: ""; 47 | display: flex; 48 | border: 1px solid transparent; 49 | } 50 | .icon-npm:hover::after, 51 | .icon-npm:focus::after { 52 | border: 1px solid currentColor; 53 | } */ 54 | 55 | .icon-github { 56 | display: flex; 57 | align-items: center; 58 | gap: 0.5rem; 59 | font-size: 0px; 60 | } 61 | .icon-github svg { 62 | display: none; 63 | } 64 | .icon-github::after { 65 | background-image: url("https://img.shields.io/github/stars/ryfylke-react-as/rtk-query-loader?style=social"); 66 | width: 82px; 67 | height: 20px; 68 | content: ""; 69 | transition: transform 0.1s ease-in-out; 70 | display: flex; 71 | } 72 | .icon-github:hover::after, 73 | .icon-github:focus::after { 74 | transform: scale(1.05); 75 | } 76 | 77 | [data-theme="light"] .DocSearch { 78 | /* --docsearch-primary-color: var(--ifm-color-primary); */ 79 | /* --docsearch-text-color: var(--ifm-font-color-base); */ 80 | --docsearch-muted-color: var(--ifm-color-secondary-darkest); 81 | --docsearch-container-background: rgba(94, 100, 112, 0.7); 82 | /* Modal */ 83 | --docsearch-modal-background: var( 84 | --ifm-color-secondary-lighter 85 | ); 86 | /* Search box */ 87 | --docsearch-searchbox-background: var(--ifm-color-secondary); 88 | --docsearch-searchbox-focus-background: var(--ifm-color-white); 89 | /* Hit */ 90 | --docsearch-hit-color: var(--ifm-font-color-base); 91 | --docsearch-hit-active-color: var(--ifm-color-white); 92 | --docsearch-hit-background: var(--ifm-color-white); 93 | /* Footer */ 94 | --docsearch-footer-background: var(--ifm-color-white); 95 | } 96 | 97 | [data-theme="dark"] .DocSearch { 98 | --docsearch-text-color: var(--ifm-font-color-base); 99 | --docsearch-muted-color: var(--ifm-color-secondary-darkest); 100 | --docsearch-container-background: rgba(47, 55, 69, 0.7); 101 | /* Modal */ 102 | --docsearch-modal-background: var(--ifm-background-color); 103 | /* Search box */ 104 | --docsearch-searchbox-background: var(--ifm-background-color); 105 | --docsearch-searchbox-focus-background: var(--ifm-color-black); 106 | /* Hit */ 107 | --docsearch-hit-color: var(--ifm-font-color-base); 108 | --docsearch-hit-active-color: var(--ifm-color-white); 109 | --docsearch-hit-background: var(--ifm-color-emphasis-100); 110 | /* Footer */ 111 | --docsearch-footer-background: var( 112 | --ifm-background-surface-color 113 | ); 114 | --docsearch-key-gradient: linear-gradient( 115 | -26.5deg, 116 | var(--ifm-color-emphasis-200) 0%, 117 | var(--ifm-color-emphasis-100) 100% 118 | ); 119 | } 120 | 121 | .DocSearch-Button-Placeholder { 122 | padding: 0 12px !important; 123 | } 124 | .DocSearch-Button-Container svg { 125 | --size: 14px; 126 | width: var(--size) !important; 127 | height: var(--size) !important; 128 | margin-left: 8px; 129 | } 130 | 131 | .navbar { 132 | background: linear-gradient(350deg, #e2e2f3, rgb(239 254 239)); 133 | } 134 | 135 | [data-theme="dark"] .navbar { 136 | background: linear-gradient(350deg, #1b1b1d, #262b2e); 137 | } 138 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/docusaurus-social-card.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/img/docusaurus-social-card.jpg -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/ryfrea-social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ryfylke-react-as/rtk-query-loader/3ed7aa5c3be00afd8d6adacb8a97a45bc27330a0/docs/static/img/ryfrea-social-card.png -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_tree.svg: -------------------------------------------------------------------------------- 1 | 2 | Focus on What Matters 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ryfylke-react/rtk-query-loader", 3 | "version": "1.0.4", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@ryfylke-react/rtk-query-loader", 9 | "version": "1.0.4", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@reduxjs/toolkit": "^2.2.3", 13 | "@types/react": "^18.0.21", 14 | "react": "^18.2.0", 15 | "tslib": "^2.4.0" 16 | }, 17 | "peerDependencies": { 18 | "@reduxjs/toolkit": "^2.2.3" 19 | } 20 | }, 21 | "node_modules/@reduxjs/toolkit": { 22 | "version": "2.2.5", 23 | "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz", 24 | "integrity": "sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg==", 25 | "dev": true, 26 | "dependencies": { 27 | "immer": "^10.0.3", 28 | "redux": "^5.0.1", 29 | "redux-thunk": "^3.1.0", 30 | "reselect": "^5.1.0" 31 | }, 32 | "peerDependencies": { 33 | "react": "^16.9.0 || ^17.0.0 || ^18", 34 | "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" 35 | }, 36 | "peerDependenciesMeta": { 37 | "react": { 38 | "optional": true 39 | }, 40 | "react-redux": { 41 | "optional": true 42 | } 43 | } 44 | }, 45 | "node_modules/@types/prop-types": { 46 | "version": "15.7.12", 47 | "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", 48 | "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", 49 | "dev": true 50 | }, 51 | "node_modules/@types/react": { 52 | "version": "18.3.3", 53 | "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", 54 | "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", 55 | "dev": true, 56 | "dependencies": { 57 | "@types/prop-types": "*", 58 | "csstype": "^3.0.2" 59 | } 60 | }, 61 | "node_modules/csstype": { 62 | "version": "3.1.3", 63 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 64 | "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 65 | "dev": true 66 | }, 67 | "node_modules/immer": { 68 | "version": "10.1.1", 69 | "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", 70 | "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", 71 | "dev": true, 72 | "funding": { 73 | "type": "opencollective", 74 | "url": "https://opencollective.com/immer" 75 | } 76 | }, 77 | "node_modules/js-tokens": { 78 | "version": "4.0.0", 79 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 80 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", 81 | "dev": true 82 | }, 83 | "node_modules/loose-envify": { 84 | "version": "1.4.0", 85 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 86 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 87 | "dev": true, 88 | "dependencies": { 89 | "js-tokens": "^3.0.0 || ^4.0.0" 90 | }, 91 | "bin": { 92 | "loose-envify": "cli.js" 93 | } 94 | }, 95 | "node_modules/react": { 96 | "version": "18.3.1", 97 | "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 98 | "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 99 | "dev": true, 100 | "dependencies": { 101 | "loose-envify": "^1.1.0" 102 | }, 103 | "engines": { 104 | "node": ">=0.10.0" 105 | } 106 | }, 107 | "node_modules/redux": { 108 | "version": "5.0.1", 109 | "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", 110 | "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", 111 | "dev": true 112 | }, 113 | "node_modules/redux-thunk": { 114 | "version": "3.1.0", 115 | "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", 116 | "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", 117 | "dev": true, 118 | "peerDependencies": { 119 | "redux": "^5.0.0" 120 | } 121 | }, 122 | "node_modules/reselect": { 123 | "version": "5.1.0", 124 | "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", 125 | "integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==", 126 | "dev": true 127 | }, 128 | "node_modules/tslib": { 129 | "version": "2.6.2", 130 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", 131 | "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", 132 | "dev": true 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ryfylke-react/rtk-query-loader", 3 | "version": "1.0.7", 4 | "description": "Lets you create reusable, extendable RTK loaders for React components.", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "types": "./dist/esm/index.d.ts", 8 | "scripts": { 9 | "build": "yarn build:esm && yarn build:cjs", 10 | "build:esm": "tsc", 11 | "build:cjs": "tsc --module commonjs --outDir dist/cjs", 12 | "prepare": "yarn build", 13 | "setup-link": "npm link && cd testing-app && npm link @ryfylke-react/rtk-query-loader" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/ryfylke-react-as/rtk-query-loader.git" 18 | }, 19 | "author": "Ryfylke React AS", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/ryfylke-react-as/rtk-query-loader/issues" 23 | }, 24 | "homepage": "https://rtk-query-loader.ryfylke.dev", 25 | "devDependencies": { 26 | "@types/react": "^18.2.37", 27 | "@types/react-dom": "^18.2.15", 28 | "@reduxjs/toolkit": "^1.6.2", 29 | "react": "^18.2.0", 30 | "tslib": "^2.4.0", 31 | "typescript": "^5.4.5" 32 | }, 33 | "peerDependencies": { 34 | "@reduxjs/toolkit": "^1.6.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/AwaitLoader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Types from "./types"; 3 | import { withLoader } from "./withLoader"; 4 | type ReactType = typeof React; 5 | let R = React; 6 | 7 | type AwaitLoaderProps< 8 | TProps extends Record, 9 | TReturn, 10 | TArg = never 11 | > = [TArg] extends [never] 12 | ? { 13 | loader: Types.Loader; 14 | render: (data: TReturn) => React.ReactElement; 15 | } 16 | : { 17 | loader: Types.Loader; 18 | render: (data: TReturn) => React.ReactElement; 19 | args: TProps; 20 | }; 21 | 22 | /** 23 | * @typedef AwaitLoaderProps 24 | * @type {Object} 25 | * @property {Types.Loader} loader The loader to use. 26 | * @property {(data: TReturn) => React.ReactElement} render The render function to use. 27 | * @property {TProps} args The arguments to pass to the loader. 28 | */ 29 | 30 | /** 31 | * A component that awaits a loader and renders the data. 32 | * @param {AwaitLoaderProps} args The arguments to pass to the loader. 33 | */ 34 | export const AwaitLoader = < 35 | TProps extends Record, 36 | TReturn extends unknown, 37 | TArg = never 38 | >( 39 | args: AwaitLoaderProps 40 | ) => { 41 | const Component = R.useCallback( 42 | withLoader( 43 | (_, loaderData) => args.render(loaderData), 44 | args.loader 45 | ), 46 | [] 47 | ); 48 | return R.createElement( 49 | Component, 50 | "args" in args ? args.args : ({} as TProps), 51 | null 52 | ); 53 | }; 54 | 55 | export const _testLoad = (react: any) => { 56 | R = react as ReactType; 57 | return AwaitLoader; 58 | }; 59 | -------------------------------------------------------------------------------- /src/RTKLoader.tsx: -------------------------------------------------------------------------------- 1 | import { SerializedError } from "@reduxjs/toolkit"; 2 | import * as React from "react"; 3 | import { CustomLoaderProps } from "./types"; 4 | 5 | /** 6 | * The default loader component for use with RTK Query Loader. 7 | */ 8 | export function RTKLoader( 9 | props: CustomLoaderProps 10 | ): React.ReactElement { 11 | const shouldLoad = 12 | props.query.isLoading || props.query.isUninitialized; 13 | const hasError = props.query.isError && props.query.error; 14 | const isFetching = props.query.isFetching; 15 | 16 | if (shouldLoad) { 17 | return props.onLoading ?? ; 18 | } 19 | 20 | if (hasError) { 21 | return ( 22 | props.onError?.(props.query.error as SerializedError) ?? ( 23 | 24 | ) 25 | ); 26 | } 27 | 28 | if (isFetching && props.onFetching) { 29 | return props.onFetching; 30 | } 31 | 32 | if (props.query.data !== undefined) { 33 | const prepend = isFetching 34 | ? props.whileFetching?.prepend ?? null 35 | : null; 36 | const append = isFetching 37 | ? props.whileFetching?.append ?? null 38 | : null; 39 | const componentWithData = props.onSuccess(props.query.data); 40 | 41 | return ( 42 | <> 43 | {prepend} 44 | {componentWithData} 45 | {append} 46 | 47 | ); 48 | } 49 | 50 | return ; 51 | } 52 | -------------------------------------------------------------------------------- /src/aggregateToQuery.ts: -------------------------------------------------------------------------------- 1 | import * as Types from "./types"; 2 | 3 | export const aggregateToQuery = ( 4 | queries: readonly Types.UseQueryResult[] 5 | ): Types.UseQueryResult => { 6 | const isLoading = queries.some((query) => query.isLoading); 7 | const isError = queries.some( 8 | (query) => 9 | query.isError || 10 | (query.isFetching && query.error && !query.data) 11 | ); 12 | const isFetching = queries.some((query) => query.isFetching); 13 | const isUninitialized = queries.some( 14 | (query) => query.isUninitialized 15 | ); 16 | const isSuccess = !isUninitialized && !isLoading && !isError; 17 | const error = queries.find( 18 | (query) => query.error !== undefined 19 | )?.error; 20 | const fulfilledTimeStamp = Math.max( 21 | ...queries 22 | .filter( 23 | (query) => typeof query.fulfilledTimeStamp === "number" 24 | ) 25 | .map((query) => query.fulfilledTimeStamp as number) 26 | ); 27 | const startedTimeStamp = Math.max( 28 | ...queries 29 | .filter( 30 | (query) => typeof query.startedTimeStamp === "number" 31 | ) 32 | .map((query) => query.startedTimeStamp as number) 33 | ); 34 | const requestId = queries 35 | .filter((query) => typeof query.requestId === "string") 36 | .map((query) => query.requestId) 37 | .join(""); 38 | 39 | const refetch = () => { 40 | queries.forEach((query) => query.refetch()); 41 | }; 42 | 43 | return { 44 | isLoading, 45 | isError, 46 | isFetching, 47 | isSuccess, 48 | isUninitialized, 49 | refetch, 50 | error, 51 | fulfilledTimeStamp, 52 | startedTimeStamp, 53 | requestId, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/createLoader.ts: -------------------------------------------------------------------------------- 1 | import { aggregateToQuery } from "./aggregateToQuery"; 2 | import { RTKLoader } from "./RTKLoader"; 3 | import * as Types from "./types"; 4 | 5 | const mergeConfig = ( 6 | original: Types.LoaderConfig, 7 | extended: Types.LoaderConfig 8 | ): Types.LoaderConfig => { 9 | if (!original) return extended; 10 | return { 11 | ...original, 12 | ...extended, 13 | deferred: { 14 | ...original.deferred, 15 | ...extended.deferred, 16 | }, 17 | }; 18 | }; 19 | 20 | export const createUseLoader = < 21 | TQueries extends Types._TQueries, 22 | TDeferred extends Types._TDeferred, 23 | TPayload extends Types._TPayload, 24 | TReturn extends unknown = Types.ResolveDataShape< 25 | Types.MakeDataRequired, 26 | TDeferred, 27 | TPayload 28 | >, 29 | TArg = never 30 | >( 31 | createUseLoaderArgs: Types.CreateUseLoaderArgs< 32 | TQueries, 33 | TDeferred, 34 | TPayload, 35 | TReturn, 36 | TArg 37 | > 38 | ): Types.UseLoader< 39 | TArg, 40 | TReturn, 41 | TQueries, 42 | TDeferred, 43 | TPayload 44 | > => { 45 | const useLoader = ( 46 | ...args: Types.OptionalGenericArg 47 | ) => { 48 | const loaderRes = createUseLoaderArgs.useQueries(...args); 49 | const queriesList = loaderRes.queries 50 | ? Object.keys(loaderRes.queries).map( 51 | (key) => (loaderRes.queries as TQueries)[key] 52 | ) 53 | : []; 54 | const aggregatedQuery = aggregateToQuery(queriesList); 55 | 56 | if (createUseLoaderArgs.config?.deferred?.shouldThrowError) { 57 | if (loaderRes.deferredQueries) { 58 | const deferredQueriesList = Object.keys( 59 | loaderRes.deferredQueries 60 | ).map( 61 | (key) => (loaderRes.deferredQueries as TDeferred)[key] 62 | ); 63 | if (deferredQueriesList.some((q) => q.isError)) { 64 | aggregatedQuery.isSuccess = false; 65 | aggregatedQuery.isError = true; 66 | aggregatedQuery.error = deferredQueriesList.find( 67 | (q) => q.isError 68 | )?.error; 69 | } 70 | } 71 | } 72 | 73 | if (aggregatedQuery.isSuccess || queriesList.length === 0) { 74 | const data = createUseLoaderArgs.transform 75 | ? createUseLoaderArgs.transform( 76 | loaderRes as Types.ResolveDataShape< 77 | Types.MakeDataRequired, 78 | TDeferred, 79 | TPayload 80 | > 81 | ) 82 | : loaderRes; 83 | 84 | return { 85 | ...aggregatedQuery, 86 | isSuccess: true, 87 | data, 88 | currentData: data, 89 | originalArgs: args, 90 | } as Types.UseQueryResult; 91 | } 92 | 93 | return aggregatedQuery as Types.UseQueryResult; 94 | }; 95 | 96 | useLoader.original_args = createUseLoaderArgs; 97 | return useLoader; 98 | }; 99 | 100 | /** 101 | * Creates a `loader` that can be used to fetch data and render error & loading states. 102 | * @example 103 | * const loader = createLoader({ 104 | * queriesArg: (props) => props.userId, 105 | * useQueries: (userId) => { 106 | * const user = useGetUserQuery(userId); 107 | * return { queries: { user } }; 108 | * }, 109 | * onError: (error) => , 110 | * onLoading: () => , 111 | * }); 112 | */ 113 | export const createLoader = < 114 | TProps extends unknown, 115 | TQueries extends Types._TQueries, 116 | TDeferred extends Types._TDeferred, 117 | TPayload extends Types._TPayload, 118 | TReturn extends unknown = Types.ResolveDataShape< 119 | Types.MakeDataRequired, 120 | TDeferred, 121 | TPayload 122 | >, 123 | TArg extends unknown = never 124 | >( 125 | createLoaderArgs: Types.CreateLoaderArgs< 126 | TProps, 127 | TQueries, 128 | TDeferred, 129 | TPayload, 130 | TReturn, 131 | TArg 132 | > 133 | ): Types.Loader< 134 | TProps, 135 | TReturn, 136 | TQueries, 137 | TDeferred, 138 | TPayload, 139 | TArg 140 | > => { 141 | const useLoader = createUseLoader({ 142 | useQueries: 143 | createLoaderArgs.useQueries ?? 144 | (() => ({} as unknown as TQueries)), 145 | transform: createLoaderArgs.transform, 146 | config: createLoaderArgs.config, 147 | }); 148 | 149 | const loader: Types.Loader< 150 | TProps, 151 | TReturn, 152 | TQueries, 153 | TDeferred, 154 | TPayload, 155 | TArg 156 | > = { 157 | useLoader, 158 | onLoading: createLoaderArgs.onLoading, 159 | onError: createLoaderArgs.onError, 160 | onFetching: createLoaderArgs.onFetching, 161 | whileFetching: createLoaderArgs.whileFetching, 162 | queriesArg: createLoaderArgs.queriesArg, 163 | config: createLoaderArgs.config, 164 | LoaderComponent: 165 | createLoaderArgs.config?.loaderComponent ?? 166 | createLoaderArgs.loaderComponent ?? 167 | RTKLoader, 168 | extend: function < 169 | E_TQueries extends Types._TQueries = TQueries, 170 | E_TDeferred extends Types._TDeferred = TDeferred, 171 | E_TPayload extends Types._TPayload = TPayload, 172 | E_TReturn extends unknown = Types.AllEql< 173 | TQueries, 174 | E_TQueries, 175 | TDeferred, 176 | E_TDeferred, 177 | TPayload, 178 | E_TPayload 179 | > extends true 180 | ? TReturn 181 | : Types.ResolveLoadedDataShape< 182 | E_TQueries, 183 | E_TDeferred, 184 | E_TPayload 185 | >, 186 | E_TProps extends unknown = TProps, 187 | E_TArg = TArg 188 | >({ 189 | useQueries, 190 | transform, 191 | ...extendedArgs 192 | }: Partial< 193 | Types.CreateLoaderArgs< 194 | E_TProps, 195 | E_TQueries, 196 | E_TDeferred, 197 | E_TPayload, 198 | E_TReturn, 199 | E_TArg 200 | > 201 | >) { 202 | const original = this as unknown as Types.Loader< 203 | E_TProps, 204 | E_TReturn, 205 | E_TQueries, 206 | E_TDeferred, 207 | E_TPayload, 208 | E_TArg 209 | >; 210 | const mergedConfig = mergeConfig( 211 | original.config ?? {}, 212 | extendedArgs.config ?? {} 213 | ); 214 | 215 | backwardsSupportLoaderComponent( 216 | mergedConfig, 217 | extendedArgs 218 | ); 219 | 220 | const extendedLoader: Types.Loader< 221 | E_TProps, 222 | E_TReturn, 223 | E_TQueries, 224 | E_TDeferred, 225 | E_TPayload, 226 | E_TArg 227 | > = { 228 | ...original, 229 | ...extendedArgs, 230 | config: mergedConfig, 231 | }; 232 | 233 | if (useQueries) { 234 | const newUseLoader = createUseLoader({ 235 | useQueries, 236 | transform, 237 | config: mergedConfig, 238 | }); 239 | extendedLoader.useLoader = newUseLoader; 240 | } else if (transform) { 241 | const newUseLoader = createUseLoader({ 242 | useQueries: 243 | extendedLoader.useLoader.original_args.useQueries, 244 | transform, 245 | config: mergedConfig, 246 | }); 247 | extendedLoader.useLoader = newUseLoader; 248 | } else if (extendedArgs.config) { 249 | const newUseLoader = createUseLoader({ 250 | useQueries: 251 | extendedLoader.useLoader.original_args.useQueries, 252 | transform: 253 | extendedLoader.useLoader.original_args.transform, 254 | config: mergedConfig, 255 | }); 256 | extendedLoader.useLoader = newUseLoader; 257 | } 258 | 259 | return extendedLoader; 260 | }, 261 | }; 262 | 263 | return loader; 264 | }; 265 | 266 | function backwardsSupportLoaderComponent( 267 | mergedConfig: Types.LoaderConfig, 268 | extendedArgs: Types.CreateLoaderArgs< 269 | any, 270 | any, 271 | any, 272 | any, 273 | any, 274 | any 275 | > 276 | ) { 277 | if ( 278 | !mergedConfig.loaderComponent && 279 | extendedArgs.loaderComponent 280 | ) { 281 | mergedConfig.loaderComponent = extendedArgs.loaderComponent; 282 | } 283 | return mergedConfig; 284 | } 285 | -------------------------------------------------------------------------------- /src/createQuery.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Types from "./types"; 3 | 4 | type ReactType = typeof React; 5 | let R = React; 6 | 7 | let requestIdCount = 0; 8 | const requestIdGenerator = () => { 9 | requestIdCount += 1; 10 | return `usecreatequery-${requestIdCount}`; 11 | }; 12 | 13 | /** 14 | * Creates a query from an async getter function. 15 | * @param getter The async function to get the data. 16 | * @param dependencies The dependency array to watch for changes. 17 | * @example 18 | * const query = useCreateQuery(async () => { 19 | * const response = await fetch(`/users/${userId}`); 20 | * return response.json(); 21 | * }, [userId]); 22 | */ 23 | export const useCreateQuery = ( 24 | getter: Types.CreateQueryGetter, 25 | dependencies?: any[] 26 | ): Types.UseQueryResult => { 27 | const safeDependencies = dependencies ?? []; 28 | const [requestId] = R.useState(() => requestIdGenerator()); 29 | const [state, dispatch] = R.useReducer(reducer, { 30 | isLoading: true, 31 | isSuccess: false, 32 | isError: false, 33 | isFetching: false, 34 | refetch: () => {}, 35 | isUninitialized: true, 36 | currentData: undefined, 37 | data: undefined, 38 | error: undefined, 39 | endpointName: "", 40 | fulfilledTimeStamp: 0, 41 | originalArgs: safeDependencies, 42 | requestId, 43 | startedTimeStamp: 0, 44 | }); 45 | 46 | const runQuery = (overrideInitialized?: boolean) => { 47 | const fetchData = async () => { 48 | try { 49 | const data = await getter(); 50 | dispatch({ type: "success", payload: { data } }); 51 | } catch (error) { 52 | dispatch({ type: "error", payload: { error } }); 53 | } 54 | }; 55 | 56 | dispatch({ 57 | type: overrideInitialized 58 | ? "fetch" 59 | : state.isUninitialized 60 | ? "load" 61 | : "fetch", 62 | payload: { refetch: () => runQuery(true) }, 63 | }); 64 | 65 | fetchData(); 66 | }; 67 | 68 | R.useEffect(() => runQuery(), safeDependencies); 69 | 70 | return state as Types.UseQueryResult; 71 | }; 72 | 73 | export const _testCreateUseCreateQuery = (react: any) => { 74 | R = react as ReactType; 75 | return useCreateQuery; 76 | }; 77 | 78 | const reducer = ( 79 | state: Types.UseQueryResult, 80 | action: Types.CreateQueryReducerAction 81 | ) => { 82 | switch (action.type) { 83 | case "load": 84 | return { 85 | ...state, 86 | isSuccess: false, 87 | isError: false, 88 | isFetching: false, 89 | isLoading: true, 90 | isUninitialized: false, 91 | startedTimeStamp: Date.now(), 92 | refetch: action.payload.refetch, 93 | }; 94 | case "fetch": 95 | return { 96 | ...state, 97 | isLoading: false, 98 | isSuccess: false, 99 | isError: false, 100 | isFetching: true, 101 | isUninitialized: false, 102 | startedTimeStamp: Date.now(), 103 | refetch: action.payload.refetch, 104 | }; 105 | case "success": 106 | return { 107 | ...state, 108 | isLoading: false, 109 | isFetching: false, 110 | isError: false, 111 | isUninitialized: false, 112 | isSuccess: true, 113 | data: action.payload.data, 114 | currentData: action.payload.data, 115 | fulfilledTimeStamp: Date.now(), 116 | }; 117 | case "error": 118 | return { 119 | ...state, 120 | isLoading: false, 121 | isSuccess: false, 122 | isFetching: false, 123 | isUninitialized: false, 124 | isError: true, 125 | error: action.payload.error, 126 | fulfilledTimeStamp: Date.now(), 127 | }; 128 | default: 129 | return state; 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { AwaitLoader } from "./AwaitLoader"; 2 | export { RTKLoader } from "./RTKLoader"; 3 | export { aggregateToQuery } from "./aggregateToQuery"; 4 | export { createLoader, createUseLoader } from "./createLoader"; 5 | export { useCreateQuery } from "./createQuery"; 6 | export type { 7 | Component, 8 | ComponentWithLoaderData, 9 | ConsumerProps, 10 | CreateLoaderArgs, 11 | CreateUseLoaderArgs, 12 | CustomLoaderProps, 13 | DataShapeInput, 14 | DeferredConfig, 15 | InferLoaderData, 16 | Loader, 17 | LoaderConfig, 18 | LoaderTransformFunction, 19 | MakeDataRequired, 20 | OptionalGenericArg, 21 | UseLoader, 22 | UseQueryResult, 23 | WithLoaderArgs, 24 | _TDeferred, 25 | _TPayload, 26 | _TProps, 27 | _TQueries, 28 | _TReturn, 29 | } from "./types"; 30 | export { useCreateLoader } from "./useCreateLoader"; 31 | export { withLoader } from "./withLoader"; 32 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { SerializedError } from "@reduxjs/toolkit"; 2 | import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; 3 | import { ReactElement } from "react"; 4 | 5 | export type Unwrap = { 6 | [K in keyof T]: T[K]; 7 | } & {}; 8 | 9 | export type AllNever< 10 | TQueries, 11 | TDeferred, 12 | TPayload, 13 | TReturn = never 14 | > = TQueries | TDeferred | TPayload | TReturn extends never 15 | ? true 16 | : false; 17 | 18 | export type AllEql< 19 | TQueries, 20 | E_TQueries, 21 | TDeferred, 22 | E_TDeferred, 23 | TPayload, 24 | E_TPayload 25 | > = TQueries extends E_TQueries 26 | ? TDeferred extends E_TDeferred 27 | ? TPayload extends E_TPayload 28 | ? true 29 | : false 30 | : false 31 | : false; 32 | 33 | /** Result of a RTK useQuery hook */ 34 | export type UseQueryResult = { 35 | // Base query state 36 | /** Arguments passed to the query */ 37 | originalArgs?: unknown; 38 | /** The latest returned result regardless of hook arg, if present */ 39 | data?: T; 40 | /** The latest returned result for the current hook arg, if present */ 41 | currentData?: T; 42 | /** Error result if present */ 43 | error?: unknown; 44 | /** A string generated by RTK Query */ 45 | requestId?: string; 46 | /** The name of the given endpoint for the query */ 47 | endpointName?: string; 48 | /** Timestamp for when the query was initiated */ 49 | startedTimeStamp?: number; 50 | /** Timestamp for when the query was completed */ 51 | fulfilledTimeStamp?: number; 52 | 53 | // Derived request status booleans 54 | /** Query has not started yet */ 55 | isUninitialized: boolean; 56 | /** Query is currently loading for the first time. No data yet. */ 57 | isLoading: boolean; 58 | /** Query is currently fetching, but might have data from an earlier request. */ 59 | isFetching: boolean; 60 | /** Query has data from a successful load */ 61 | isSuccess: boolean; 62 | /** Query is currently in an "error" state */ 63 | isError: boolean; 64 | 65 | /** A function to force refetch the query */ 66 | refetch: () => void; 67 | }; 68 | 69 | /** _X are types that are extended from in the generics */ 70 | export type _TQueries = 71 | | Record> 72 | | never; 73 | export type _TDeferred = 74 | | Record> 75 | | never; 76 | export type _TPayload = unknown | never; 77 | export type _TProps = Record; 78 | export type _TReturn = unknown; 79 | 80 | export type MakeDataRequired = { 81 | // @ts-ignore: TS2536: Type '"data"' cannot be used to index type 'T[K]'. 82 | [K in keyof T]: T[K] & { data: NonNullable }; 83 | }; 84 | 85 | export type DataShapeInput< 86 | TQueries extends _TQueries, 87 | TDeferred extends _TDeferred, 88 | TPayload extends _TPayload 89 | > = { 90 | queries?: TQueries; 91 | deferredQueries?: TDeferred; 92 | payload?: TPayload; 93 | /** This should be specified at the **top level** of `createLoader` or `extend` */ 94 | onLoading?: never; 95 | /** This should be specified at the **top level** of `createLoader` or `extend` */ 96 | onError?: never; 97 | /** This should be specified at the **top level** of `createLoader` or `extend` */ 98 | onFetching?: never; 99 | /** This should be specified at the **top level** of `createLoader` or `extend` */ 100 | whileFetching?: never; 101 | }; 102 | 103 | export type ResolveDataShape< 104 | TQueries extends _TQueries, 105 | TDeferred extends _TDeferred, 106 | TPayload extends _TPayload 107 | > = { 108 | queries: TQueries extends never ? never : TQueries; 109 | deferredQueries: TDeferred extends never ? never : TDeferred; 110 | payload: TPayload extends never ? never : TPayload; 111 | }; 112 | 113 | export type ResolveLoadedDataShape< 114 | TQueries extends _TQueries, 115 | TDeferred extends _TDeferred, 116 | TPayload extends _TPayload 117 | > = ResolveDataShape< 118 | MakeDataRequired, 119 | TDeferred, 120 | TPayload 121 | >; 122 | 123 | /** Use: `(...args: OptionalGenericArg) => void;` 124 | * Allows either `T` or `none` for the parameter 125 | */ 126 | export type OptionalGenericArg = T extends never ? [] : [T]; 127 | 128 | export type LoaderTransformFunction< 129 | TQueries extends _TQueries, 130 | TDeferred extends _TDeferred, 131 | TPayload extends _TPayload, 132 | TReturn extends unknown 133 | > = ( 134 | data: ResolveLoadedDataShape 135 | ) => TReturn; 136 | 137 | export type CreateUseLoaderArgs< 138 | TQueries extends _TQueries, 139 | TDeferred extends _TDeferred, 140 | TPayload extends _TPayload, 141 | TReturn extends _TReturn, 142 | TArg = never 143 | > = { 144 | /** Should return a list of RTK useQuery results. 145 | * Example: 146 | * ```typescript 147 | * (args: Args) => ({ 148 | * queries: { 149 | * pokemon: useGetPokemonQuery(args.pokemonId), 150 | * } 151 | * }) 152 | * ``` 153 | */ 154 | useQueries: ( 155 | ...args: OptionalGenericArg 156 | ) => DataShapeInput; 157 | /** Transforms the output of the queries */ 158 | transform?: LoaderTransformFunction< 159 | TQueries, 160 | TDeferred, 161 | TPayload, 162 | TReturn 163 | >; 164 | config?: LoaderConfig; 165 | }; 166 | 167 | export type UseLoader< 168 | TArg, 169 | TReturn, 170 | TQueries extends _TQueries, 171 | TDeferred extends _TDeferred, 172 | TPayload 173 | > = { 174 | (...args: OptionalGenericArg): UseQueryResult; 175 | original_args: CreateUseLoaderArgs< 176 | TQueries, 177 | TDeferred, 178 | TPayload, 179 | TReturn, 180 | TArg 181 | >; 182 | }; 183 | export type ComponentWithLoaderData< 184 | TProps extends Record, 185 | TReturn extends unknown 186 | > = (props: TProps, loaderData: TReturn) => ReactElement; 187 | 188 | /** Use: `InferLoaderData`. Returns the return-value of the given loader's aggregated query. */ 189 | export type InferLoaderData = T extends 190 | | Loader 191 | | Loader 192 | | Loader 193 | | Loader 194 | | Loader 195 | | Loader 196 | | Loader 197 | | Loader 198 | | Loader 199 | | Loader 200 | | Loader 201 | | Loader 202 | | Loader 203 | | Loader 204 | | Loader 205 | | Loader 206 | | Loader 207 | | Loader 208 | | Loader 209 | ? R 210 | : never; 211 | 212 | export type Component> = ( 213 | props: TProps 214 | ) => ReactElement; 215 | 216 | export type WhileFetchingArgs< 217 | TProps extends unknown, 218 | TReturn extends unknown 219 | > = { 220 | /** Will be prepended before the component while the query is fetching */ 221 | prepend?: (props: TProps, data?: TReturn) => ReactElement; 222 | /** Will be appended after the component while the query is fetching */ 223 | append?: (props: TProps, data?: TReturn) => ReactElement; 224 | }; 225 | 226 | export type CustomLoaderProps = { 227 | /** What the loader requests be rendered while fetching data */ 228 | onFetching?: React.ReactElement; 229 | /** What the loader requests be rendered while fetching data */ 230 | whileFetching?: { 231 | /** Should be appended to the success result while fetching */ 232 | append?: React.ReactElement; 233 | /** Should be prepended to the success result while fetching */ 234 | prepend?: React.ReactElement; 235 | }; 236 | /** What the loader requests be rendered when data is available */ 237 | onSuccess: (data: T) => React.ReactElement; 238 | /** What the loader requests be rendered when the query fails */ 239 | onError?: ( 240 | error: SerializedError | FetchBaseQueryError 241 | ) => JSX.Element; 242 | /** What the loader requests be rendered while loading data */ 243 | onLoading?: React.ReactElement; 244 | config?: LoaderConfig; 245 | /** The joined query for the loader */ 246 | query: UseQueryResult; 247 | }; 248 | 249 | export type DeferredConfig = { 250 | shouldThrowError?: boolean; 251 | }; 252 | 253 | export type LoaderConfig = { 254 | deferred?: DeferredConfig; 255 | loaderComponent?: Component; 256 | }; 257 | 258 | export type CreateLoaderArgs< 259 | TProps extends unknown, 260 | TQueries extends _TQueries, 261 | TDeferred extends _TDeferred, 262 | TPayload extends _TPayload, 263 | TReturn extends unknown, 264 | TArg = never 265 | > = Partial< 266 | CreateUseLoaderArgs< 267 | TQueries, 268 | TDeferred, 269 | TPayload, 270 | TReturn, 271 | TArg 272 | > 273 | > & { 274 | /** Generates an argument for the `queries` based on component props */ 275 | queriesArg?: (props: TProps) => TArg; 276 | /** Determines what to render while loading (with no data to fallback on) */ 277 | onLoading?: ( 278 | props: TProps, 279 | joinedQuery: UseQueryResult 280 | ) => ReactElement; 281 | /** Determines what to render when query fails. */ 282 | onError?: ( 283 | props: TProps, 284 | error: FetchBaseQueryError | SerializedError, 285 | joinedQuery: UseQueryResult 286 | ) => ReactElement; 287 | /** @deprecated Using onFetching might result in loss of internal state. Use `whileFetching` instead, or pass the query to the component */ 288 | onFetching?: ( 289 | props: TProps, 290 | renderBody: () => ReactElement 291 | ) => ReactElement; 292 | /** Determines what to render besides success-result while query is fetching. */ 293 | whileFetching?: WhileFetchingArgs; 294 | config?: LoaderConfig; 295 | /** @deprecated Use `config.loaderComponent` */ 296 | loaderComponent?: Component; 297 | }; 298 | 299 | export type CreateLoader< 300 | TProps extends unknown, 301 | TQueries extends _TQueries = never, 302 | TDeferred extends _TDeferred = never, 303 | TPayload extends _TPayload = never, 304 | TReturn extends unknown = MakeDataRequired, 305 | TArg = never 306 | > = ( 307 | args: CreateLoaderArgs< 308 | TProps, 309 | TQueries, 310 | TDeferred, 311 | TPayload, 312 | TReturn, 313 | TArg 314 | > 315 | ) => Loader< 316 | TProps, 317 | TReturn, 318 | TQueries, 319 | TDeferred, 320 | TPayload, 321 | TArg 322 | >; 323 | 324 | export type Loader< 325 | TProps extends unknown, 326 | TReturn extends unknown, 327 | TQueries extends _TQueries = never, 328 | TDeferred extends _TDeferred = never, 329 | TPayload extends _TPayload = never, 330 | TArg = never 331 | > = { 332 | /** A hook that runs all queries and returns aggregated result */ 333 | useLoader: UseLoader< 334 | TArg, 335 | TReturn, 336 | TQueries, 337 | TDeferred, 338 | TPayload 339 | >; 340 | /** Generates an argument for the `queries` based on component props */ 341 | queriesArg?: (props: TProps) => TArg; 342 | /** Determines what to render while loading (with no data to fallback on) */ 343 | onLoading?: ( 344 | props: TProps, 345 | joinedQuery: UseQueryResult 346 | ) => ReactElement; 347 | /** Determines what to render when query fails. */ 348 | onError?: ( 349 | props: TProps, 350 | error: SerializedError | FetchBaseQueryError, 351 | joinedQuery: UseQueryResult 352 | ) => ReactElement; 353 | /** @deprecated Using onFetching might result in loss of internal state. Use `whileFetching` instead, or pass the query to the component */ 354 | onFetching?: ( 355 | props: TProps, 356 | renderBody: () => ReactElement 357 | ) => ReactElement; 358 | /** Determines what to render besides success-result while query is fetching. */ 359 | whileFetching?: WhileFetchingArgs; 360 | config?: LoaderConfig; 361 | /** 362 | * Creates a `loader` that can be used to fetch data and render error & loading states. 363 | * @example 364 | * const loader = baseLoader.extend({ 365 | * queriesArg: (props) => props.userId, 366 | * useQueries: (userId) => { 367 | * const user = useGetUserQuery(userId); 368 | * return { queries: { user } }; 369 | * }, 370 | * }); 371 | */ 372 | extend: < 373 | E_TQueries extends _TQueries = TQueries, 374 | E_TDeferred extends _TDeferred = TDeferred, 375 | E_TPayload extends _TPayload = TPayload, 376 | E_TReturn extends unknown = AllEql< 377 | TQueries, 378 | E_TQueries, 379 | TDeferred, 380 | E_TDeferred, 381 | TPayload, 382 | E_TPayload 383 | > extends true 384 | ? TReturn 385 | : ResolveLoadedDataShape< 386 | E_TQueries, 387 | E_TDeferred, 388 | E_TPayload 389 | >, 390 | E_TProps extends unknown = TProps, 391 | E_TArg = TArg 392 | >( 393 | newLoader: Partial< 394 | CreateLoaderArgs< 395 | E_TProps, 396 | E_TQueries, 397 | E_TDeferred, 398 | E_TPayload, 399 | E_TReturn, 400 | E_TArg 401 | > 402 | > 403 | ) => Loader< 404 | E_TProps, 405 | E_TReturn, 406 | E_TQueries, 407 | E_TDeferred, 408 | E_TPayload, 409 | E_TArg 410 | >; 411 | /** The component to use to switch between rendering the different query states. */ 412 | LoaderComponent: Component; 413 | }; 414 | 415 | export type CreateQueryGetter = 416 | () => Promise; 417 | 418 | export type CreateQueryReducerAction = 419 | | { 420 | type: "load"; 421 | payload: { 422 | refetch: () => void; 423 | }; 424 | } 425 | | { 426 | type: "fetch"; 427 | payload: { 428 | refetch: () => void; 429 | }; 430 | } 431 | | { 432 | type: "error"; 433 | payload: { 434 | error: unknown; 435 | }; 436 | } 437 | | { 438 | type: "success"; 439 | payload: { 440 | data: T; 441 | }; 442 | }; 443 | 444 | export type ConsumerProps> = 445 | Record & T; 446 | 447 | /************************************************/ 448 | /* Legacy/unused, for backwards compatibility */ 449 | /************************************************/ 450 | export type WithLoaderArgs< 451 | TProps extends unknown, 452 | TReturn extends unknown, 453 | TArg = never 454 | > = Loader; 455 | -------------------------------------------------------------------------------- /src/useCreateLoader.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as Types from "./types"; 3 | import { createLoader } from "./createLoader"; 4 | 5 | /** 6 | * Allows you to create a loader inside of a component. 7 | * This is useful if you want to create a loader for use with `AwaitLoader`, scoped to a component. 8 | */ 9 | export const useCreateLoader = < 10 | TProps extends unknown, 11 | TQueries extends Types._TQueries, 12 | TDeferred extends Types._TDeferred, 13 | TPayload extends Types._TPayload, 14 | TReturn extends unknown = Types.ResolveDataShape< 15 | Types.MakeDataRequired, 16 | TDeferred, 17 | TPayload 18 | >, 19 | TArg extends unknown = never 20 | >( 21 | createLoaderArgs: Types.CreateLoaderArgs< 22 | TProps, 23 | TQueries, 24 | TDeferred, 25 | TPayload, 26 | TReturn, 27 | TArg 28 | > 29 | ) => { 30 | return React.useRef(createLoader(createLoaderArgs)).current; 31 | }; 32 | -------------------------------------------------------------------------------- /src/withLoader.tsx: -------------------------------------------------------------------------------- 1 | import { SerializedError } from "@reduxjs/toolkit"; 2 | import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; 3 | import * as React from "react"; 4 | import * as Types from "./types"; 5 | 6 | /** 7 | * A higher order component that wraps a component and provides it with a loader. 8 | * @param Component The component to wrap with a loader. Second argument is the resolved loader data. 9 | * @param loader The loader to use. 10 | * @returns A component that will load the data and pass it to the wrapped component. 11 | * @example 12 | * const Component = withLoader((props, loaderData) => { 13 | * return
{loaderData.queries.user.name}
; 14 | * }, loader); 15 | */ 16 | export const withLoader = < 17 | TProps extends Record, 18 | TReturn extends unknown, 19 | TQueries extends Types._TQueries, 20 | TDeferred extends Types._TDeferred, 21 | TPayload extends Types._TPayload, 22 | TArg = never 23 | >( 24 | Component: Types.ComponentWithLoaderData< 25 | TProps, 26 | Types.Unwrap 27 | >, 28 | loader: Types.Loader< 29 | TProps, 30 | TReturn, 31 | TQueries, 32 | TDeferred, 33 | TPayload, 34 | TArg 35 | > 36 | ): Types.Component => { 37 | let CachedComponent: Types.ComponentWithLoaderData< 38 | TProps, 39 | TReturn 40 | >; 41 | const LoadedComponent = (props: TProps) => { 42 | const useLoaderArgs = []; 43 | if (loader.queriesArg) { 44 | useLoaderArgs.push(loader.queriesArg(props)); 45 | } 46 | const query = loader.useLoader( 47 | ...(useLoaderArgs as Types.OptionalGenericArg) 48 | ); 49 | if (!CachedComponent) { 50 | CachedComponent = React.forwardRef( 51 | Component as unknown as React.ForwardRefRenderFunction< 52 | TReturn, 53 | TProps 54 | > 55 | ) as unknown as Types.ComponentWithLoaderData< 56 | TProps, 57 | TReturn 58 | >; 59 | } 60 | 61 | const onLoading = loader.onLoading?.(props, query); 62 | 63 | const onError = loader.onError 64 | ? (error: SerializedError | FetchBaseQueryError) => { 65 | if (!loader.onError) return ; 66 | return loader.onError(props, error, query); 67 | } 68 | : undefined; 69 | 70 | const onSuccess = (data: TReturn) => ( 71 | 72 | ); 73 | 74 | const whileFetching = loader.whileFetching 75 | ? { 76 | prepend: loader.whileFetching.prepend?.( 77 | props, 78 | query?.data 79 | ), 80 | append: loader.whileFetching.append?.( 81 | props, 82 | query?.data 83 | ), 84 | } 85 | : undefined; 86 | 87 | const onFetching = loader?.onFetching?.( 88 | props, 89 | query.data 90 | ? () => onSuccess(query.data as TReturn) 91 | : () => 92 | ); 93 | 94 | const { LoaderComponent } = loader; 95 | 96 | return ( 97 | React.ReactElement 102 | } 103 | onError={onError} 104 | onLoading={onLoading} 105 | query={query} 106 | /> 107 | ); 108 | }; 109 | return LoadedComponent; 110 | }; 111 | -------------------------------------------------------------------------------- /testing-app/.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 | -------------------------------------------------------------------------------- /testing-app/README.md: -------------------------------------------------------------------------------- 1 | ## Setup tests 2 | 3 | > Ensure you are using node 18+ 4 | 5 | 1. Install dependencies in parent repository (`yarn install`) 6 | 2. Run `yarn setup-link` 7 | 3. Navigate to testing app `cd testing-app` 8 | 4. Install dependencies `yarn install` 9 | 5. Run tests `yarn test` 10 | -------------------------------------------------------------------------------- /testing-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtk-query-loader-testing-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "src/index.tsx", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^14.4.3", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^20.12.12", 12 | "@types/react": "^18.2.37", 13 | "@types/react-dom": "^18.2.15", 14 | "jest-environment-jsdom": "^29.7.0", 15 | "jest-fixed-jsdom": "^0.0.2", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-scripts": "5.0.1", 19 | "typescript": "^4.8.4", 20 | "undici": "^6.18.1", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "test": "react-scripts test" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@reduxjs/toolkit": "^2.2.5", 46 | "@testing-library/dom": "^8.19.0", 47 | "msw": "^1.0.0", 48 | "react-redux": "^8.0.4", 49 | "redux": "^4.2.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /testing-app/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export class ErrorBoundary extends React.Component< 4 | { 5 | children?: React.ReactNode; 6 | fallback?: React.ReactNode; 7 | }, 8 | { 9 | hasError: boolean; 10 | } 11 | > { 12 | public state = { 13 | hasError: false, 14 | }; 15 | 16 | public static getDerivedStateFromError(_: Error) { 17 | return { hasError: true }; 18 | } 19 | 20 | public componentDidCatch( 21 | error: Error, 22 | errorInfo: React.ErrorInfo 23 | ) { 24 | console.error("Uncaught error:", error, errorInfo); 25 | } 26 | 27 | public render() { 28 | if (this.state.hasError) { 29 | return ( 30 |

{this.props.fallback ?? "_error_boundary_"}

31 | ); 32 | } 33 | 34 | return this.props.children; 35 | } 36 | } 37 | 38 | export default ErrorBoundary; 39 | -------------------------------------------------------------------------------- /testing-app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | 4 | const root = ReactDOM.createRoot( 5 | document.getElementById("root") as HTMLElement 6 | ); 7 | root.render( 8 | 9 |
10 | 11 | ); 12 | -------------------------------------------------------------------------------- /testing-app/src/mocks.ts: -------------------------------------------------------------------------------- 1 | import { rest } from "msw"; 2 | 3 | const RESPONSE_DELAY = 100; 4 | 5 | export const handlers = [ 6 | rest.get("/pokemons", (req, res, c) => { 7 | return res( 8 | c.delay(RESPONSE_DELAY), 9 | c.status(200), 10 | c.json({ 11 | results: [ 12 | { 13 | name: "charizard", 14 | }, 15 | { 16 | name: "pikachu", 17 | }, 18 | ], 19 | }) 20 | ); 21 | }), 22 | rest.get("/pokemon/:name", (req, res, c) => { 23 | if (req.params.name === "error") { 24 | return res(c.delay(RESPONSE_DELAY), c.status(500)); 25 | } 26 | if (req.params.name === "unprocessable") { 27 | return res( 28 | c.delay(RESPONSE_DELAY), 29 | c.status(422), 30 | c.json({ some_json_data: "woop" }) 31 | ); 32 | } 33 | const delay = 34 | req.params.name === "delay" 35 | ? RESPONSE_DELAY + 100 36 | : RESPONSE_DELAY; 37 | return res( 38 | c.delay(delay), 39 | c.status(200), 40 | c.json({ 41 | name: req.params.name, 42 | id: req.params.name.length, 43 | }) 44 | ); 45 | }), 46 | ]; 47 | -------------------------------------------------------------------------------- /testing-app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /testing-app/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | import { setupServer } from "msw/node"; 7 | import { handlers } from "./mocks"; 8 | import { someApi, store } from "./store"; 9 | 10 | const server = setupServer(...handlers); 11 | beforeAll(() => server.listen()); 12 | beforeEach(() => store.dispatch(someApi.util.resetApiState())); 13 | afterEach(() => server.resetHandlers()); 14 | afterAll(() => { 15 | server.close(); 16 | }); 17 | -------------------------------------------------------------------------------- /testing-app/src/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import { 3 | createApi, 4 | fetchBaseQuery, 5 | } from "@reduxjs/toolkit/query/react"; 6 | 7 | export type Pokemon = { 8 | id: number; 9 | name: string; 10 | }; 11 | 12 | export type Pokemons = { 13 | results: { 14 | name: string; 15 | }[]; 16 | }; 17 | 18 | export const someApi = createApi({ 19 | reducerPath: "pokemonApi", 20 | baseQuery: fetchBaseQuery({ 21 | baseUrl: "/", 22 | }), 23 | endpoints: (builder) => ({ 24 | getPokemonByName: builder.query({ 25 | query: (name) => `pokemon/${name || "charizard"}`, 26 | }), 27 | getPokemons: builder.query({ 28 | query: () => `pokemons`, 29 | }), 30 | }), 31 | }); 32 | 33 | export const { useGetPokemonByNameQuery, useGetPokemonsQuery } = 34 | someApi; 35 | 36 | export const store = configureStore({ 37 | reducer: { 38 | [someApi.reducerPath]: someApi.reducer, 39 | }, 40 | middleware: (getdefaultMiddleware) => 41 | getdefaultMiddleware().concat([someApi.middleware]), 42 | }); 43 | -------------------------------------------------------------------------------- /testing-app/src/testComponents.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import { useRef, useState } from "react"; 3 | import { aggregateToQuery } from "../../src/aggregateToQuery"; 4 | import { createLoader } from "../../src/createLoader"; 5 | import { InferLoaderData } from "../../src/types"; 6 | import { withLoader } from "../../src/withLoader"; 7 | import { 8 | Pokemon, 9 | Pokemons, 10 | useGetPokemonByNameQuery, 11 | useGetPokemonsQuery, 12 | } from "./store"; 13 | 14 | const RenderPokemonData = (props: { 15 | pokemon: Pokemon; 16 | pokemons: Pokemons; 17 | }) => ( 18 |
19 |

20 | {props.pokemon.name}: {props.pokemon.id} 21 |

22 |
    23 | {props.pokemons.results.map((item) => ( 24 |
  • {item.name}
  • 25 | ))} 26 |
27 |
28 | ); 29 | 30 | const simpleLoader = createLoader({ 31 | useQueries: () => ({ 32 | queries: { 33 | charizard: useGetPokemonByNameQuery("charizard"), 34 | pokemons: useGetPokemonsQuery(undefined), 35 | }, 36 | }), 37 | onLoading: () =>
Loading
, 38 | }); 39 | 40 | export const SimpleLoadedComponent = withLoader( 41 | (_, loader) => ( 42 | 46 | ), 47 | simpleLoader 48 | ); 49 | 50 | const extendedLoader = simpleLoader.extend({ 51 | onLoading: () =>
Extended loading
, 52 | }); 53 | 54 | export const ExtendedLoaderComponent = withLoader( 55 | (_, loaderData) => ( 56 | 60 | ), 61 | extendedLoader 62 | ); 63 | 64 | const pokemonByNameLoader = createLoader({ 65 | queriesArg: (props: { name: string }) => props.name, 66 | useQueries: (name) => { 67 | const pokemon = useGetPokemonByNameQuery(name); 68 | return { 69 | queries: { 70 | pokemon, 71 | }, 72 | }; 73 | }, 74 | }); 75 | 76 | export const LoadPokemon = withLoader( 77 | (props, { queries: { pokemon } }) => { 78 | return ( 79 |
80 | Loaded: "{pokemon.data.name}", props: "{props.name}" 81 |
82 | ); 83 | }, 84 | pokemonByNameLoader 85 | ); 86 | 87 | export const FailTester = withLoader( 88 | () =>
Success
, 89 | createLoader({ 90 | useQueries: () => ({ 91 | queries: { 92 | error: useGetPokemonByNameQuery("error"), 93 | }, 94 | }), 95 | onError: () =>
Error
, 96 | onLoading: () =>
Loading
, 97 | }) 98 | ); 99 | 100 | const fetchTestBaseLoader = createLoader({ 101 | useQueries: (name: string) => ({ 102 | queries: { 103 | pokemon: useGetPokemonByNameQuery(name), 104 | }, 105 | }), 106 | queriesArg: (props: { 107 | name: string; 108 | onChange: (name: string) => void; 109 | }) => props.name, 110 | onLoading: () =>
Loading
, 111 | onFetching: () =>
Fetching
, 112 | }); 113 | 114 | type FetchTestLoader = InferLoaderData< 115 | // ^? 116 | typeof fetchTestBaseLoader 117 | >; 118 | 119 | const FetchTesterComponent = ( 120 | props: { 121 | name: string; 122 | onChange: (name: string) => void; 123 | }, 124 | loaderData: FetchTestLoader 125 | ) => { 126 | const inputRef = useRef(null); 127 | return ( 128 |
129 | #{loaderData.queries.pokemon.data.id} 130 |
131 |
{ 133 | e.preventDefault(); 134 | props.onChange(inputRef.current?.value ?? ""); 135 | }} 136 | > 137 | 138 | 139 |
140 |
141 | ); 142 | }; 143 | 144 | export const FetchTestRenderer = (props: { 145 | while?: boolean; 146 | }) => { 147 | const [name, setName] = useState("charizard"); 148 | 149 | if (props.while) { 150 | return ; 151 | } 152 | return ; 153 | }; 154 | 155 | export const FetchTester = withLoader( 156 | FetchTesterComponent, 157 | fetchTestBaseLoader 158 | ); 159 | 160 | export const WhileFetchTester = withLoader( 161 | FetchTesterComponent, 162 | fetchTestBaseLoader.extend({ 163 | whileFetching: { prepend: () =>
FetchingWhile
}, 164 | onFetching: undefined, 165 | }) 166 | ); 167 | 168 | export const TestAggregateComponent = () => { 169 | const q1 = useGetPokemonByNameQuery("charizard"); 170 | const q2 = useGetPokemonsQuery(undefined); 171 | const query = aggregateToQuery([q1, q2] as const); 172 | 173 | if (query.isSuccess) { 174 | return ( 175 |
176 |

177 | {q1.data?.name}: {q1?.data?.id} 178 |

179 |
    180 | {q2.data?.results?.map((res) => ( 181 |
  • {res.name}
  • 182 | ))} 183 |
184 |
185 | ); 186 | } 187 | 188 | return
Loading
; 189 | }; 190 | 191 | const transformLoader = createLoader({ 192 | useQueries: () => ({ 193 | queries: { 194 | charizard: useGetPokemonByNameQuery("charizard"), 195 | }, 196 | }), 197 | transform: (loader) => ({ 198 | pokemon: loader.queries.charizard.data, 199 | }), 200 | }); 201 | 202 | export const TestTransformed = withLoader((_, loaderData) => { 203 | return
{loaderData.pokemon.name}
; 204 | }, transformLoader); 205 | -------------------------------------------------------------------------------- /testing-app/src/tests.test.tsx: -------------------------------------------------------------------------------- 1 | import userEvent from "@testing-library/user-event"; 2 | import * as React from "react"; 3 | import { _testLoad } from "../../src/AwaitLoader"; 4 | import { createLoader } from "../../src/createLoader"; 5 | import { _testCreateUseCreateQuery } from "../../src/createQuery"; 6 | import { CustomLoaderProps } from "../../src/types"; 7 | import { withLoader } from "../../src/withLoader"; 8 | import ErrorBoundary from "./components/ErrorBoundary"; 9 | import { 10 | useGetPokemonByNameQuery, 11 | useGetPokemonsQuery, 12 | } from "./store"; 13 | import { 14 | ExtendedLoaderComponent, 15 | FailTester, 16 | FetchTestRenderer, 17 | LoadPokemon, 18 | SimpleLoadedComponent, 19 | TestAggregateComponent, 20 | } from "./testComponents"; 21 | import { 22 | render, 23 | screen, 24 | waitFor, 25 | waitForElementToBeRemoved, 26 | } from "./utils"; 27 | 28 | const { useState } = React; 29 | 30 | // We do this to avoid two conflicting versions of React 31 | const useCreateQuery = _testCreateUseCreateQuery(React); 32 | const AwaitLoader = _testLoad(React); 33 | 34 | describe("aggregateToQuery", () => { 35 | test("It aggregates query status", async () => { 36 | render(); 37 | expect(screen.getByText("Loading")).toBeVisible(); 38 | await waitFor(() => 39 | expect(screen.getByText("charizard: 9")).toBeVisible() 40 | ); 41 | }); 42 | }); 43 | 44 | describe("useCreateQuery", () => { 45 | test("It creates a query", async () => { 46 | const Component = withLoader( 47 | (props, queries) => ( 48 |
{queries.queries.q.data.name}
49 | ), 50 | createLoader({ 51 | useQueries: () => ({ 52 | queries: { 53 | q: useCreateQuery(async () => { 54 | await new Promise((resolve) => 55 | setTimeout(resolve, 100) 56 | ); 57 | return { 58 | name: "charizard", 59 | }; 60 | }), 61 | }, 62 | }), 63 | onLoading: () =>
Loading
, 64 | }) 65 | ); 66 | render(); 67 | expect(screen.getByText("Loading")).toBeVisible(); 68 | await waitFor(() => 69 | expect(screen.getByText("charizard")).toBeVisible() 70 | ); 71 | }); 72 | test("The query can throw error", async () => { 73 | const Component = withLoader( 74 | (props, loader) =>
{loader.queries.q.data.name}
, 75 | createLoader({ 76 | useQueries: () => ({ 77 | queries: { 78 | q: useCreateQuery(async () => { 79 | await new Promise((resolve, reject) => 80 | setTimeout( 81 | () => reject(new Error("error-message")), 82 | 100 83 | ) 84 | ); 85 | return { 86 | name: "charizard", 87 | }; 88 | }), 89 | }, 90 | }), 91 | onLoading: () =>
Loading
, 92 | onError: (props, error) => ( 93 |
{(error as any)?.message}
94 | ), 95 | }) 96 | ); 97 | render(); 98 | expect(screen.getByText("Loading")).toBeVisible(); 99 | await waitFor(() => 100 | expect(screen.getByText("error-message")).toBeVisible() 101 | ); 102 | }); 103 | test("You can refetch the query", async () => { 104 | let count = 0; 105 | const Component = withLoader( 106 | (props, queries) => { 107 | return ( 108 |
109 | {queries.queries.q.isFetching ? "fetch" : "success"}{" "} 110 | 113 |
{queries.queries.q.data.count}
114 |
115 | ); 116 | }, 117 | createLoader({ 118 | useQueries: () => ({ 119 | queries: { 120 | q: useCreateQuery(async () => { 121 | count++; 122 | await new Promise((resolve) => 123 | setTimeout(resolve, 300) 124 | ); 125 | return { 126 | name: "charizard", 127 | count, 128 | }; 129 | }), 130 | }, 131 | }), 132 | onLoading: () =>
Loading
, 133 | }) 134 | ); 135 | render(); 136 | expect(screen.getByText("Loading")).toBeVisible(); 137 | await waitFor(() => 138 | expect(screen.getByText("success")).toBeVisible() 139 | ); 140 | expect(screen.getByText("1")).toBeVisible(); 141 | await userEvent.click(screen.getByText("refetch")); 142 | 143 | await waitFor(() => 144 | expect(screen.getByText("fetch")).toBeVisible() 145 | ); 146 | 147 | await waitFor(() => 148 | expect(screen.getByText("success")).toBeVisible() 149 | ); 150 | expect(screen.getByText("2")).toBeVisible(); 151 | }); 152 | }); 153 | 154 | describe("", () => { 155 | test("Renders loading state until data is available", async () => { 156 | const loader = createLoader({ 157 | useQueries: () => ({ 158 | queries: { 159 | charizard: useGetPokemonByNameQuery("charizard"), 160 | }, 161 | }), 162 | onLoading: () =>
Loading
, 163 | }); 164 | const Component = () => { 165 | return ( 166 |
167 | ( 170 |
{data.queries.charizard.data.name}
171 | )} 172 | /> 173 |
174 | ); 175 | }; 176 | render(); 177 | 178 | expect(screen.getByText("Loading")).toBeVisible(); 179 | await waitFor(() => 180 | expect(screen.getByText("charizard")).toBeVisible() 181 | ); 182 | }); 183 | 184 | test("Will pass arguments properly", async () => { 185 | const loader = createLoader({ 186 | queriesArg: (props: { name: string }) => props.name, 187 | useQueries: (name) => ({ 188 | queries: { 189 | charizard: useGetPokemonByNameQuery(name), 190 | }, 191 | }), 192 | onLoading: () =>
Loading
, 193 | }); 194 | const Component = () => { 195 | return ( 196 |
197 | ( 200 |
{data.queries.charizard.data.name}
201 | )} 202 | args={{ 203 | name: "charizard", 204 | }} 205 | /> 206 |
207 | ); 208 | }; 209 | render(); 210 | 211 | expect(screen.getByText("Loading")).toBeVisible(); 212 | await waitFor(() => 213 | expect(screen.getByText("charizard")).toBeVisible() 214 | ); 215 | }); 216 | }); 217 | 218 | describe("withLoader", () => { 219 | test("Renders loading state until data is available", async () => { 220 | render(); 221 | expect(screen.getByText("Loading")).toBeVisible(); 222 | await waitFor(() => 223 | expect(screen.getByText("charizard: 9")).toBeVisible() 224 | ); 225 | expect(screen.getByText("pikachu")).toBeVisible(); 226 | }); 227 | 228 | test("onError renders when applicable", async () => { 229 | render(); 230 | expect(screen.getByText("Loading")).toBeVisible(); 231 | await waitFor(() => 232 | expect(screen.getByText("Error")).toBeVisible() 233 | ); 234 | }); 235 | 236 | test("onFetching renders when applicable", async () => { 237 | render(); 238 | await waitForElementToBeRemoved(() => 239 | screen.queryByText("Loading") 240 | ); 241 | await waitFor(() => 242 | expect(screen.getByRole("textbox")).toBeVisible() 243 | ); 244 | const input = screen.getByRole("textbox"); 245 | userEvent.type(input, "Abc{Enter}"); 246 | await waitFor(() => 247 | expect(screen.getByText("Fetching")).toBeVisible() 248 | ); 249 | await waitForElementToBeRemoved(() => 250 | screen.queryByText("Fetching") 251 | ); 252 | await waitFor(() => 253 | expect(screen.getByText("#3")).toBeVisible() 254 | ); 255 | }); 256 | 257 | test("Internal state won't reset when using whileFetching", async () => { 258 | render(); 259 | await waitForElementToBeRemoved(() => 260 | screen.queryByText("Loading") 261 | ); 262 | await waitFor(() => 263 | expect(screen.getByRole("textbox")).toBeVisible() 264 | ); 265 | const input = screen.getByRole("textbox"); 266 | userEvent.type(input, "Abc{Enter}"); 267 | await waitFor(() => 268 | expect(screen.getByText("FetchingWhile")).toBeVisible() 269 | ); 270 | await waitForElementToBeRemoved(() => 271 | screen.queryByText("FetchingWhile") 272 | ); 273 | await waitFor(() => 274 | expect(screen.getByText("#3")).toBeVisible() 275 | ); 276 | expect(screen.getByRole("textbox")).toHaveValue("Abc"); 277 | }); 278 | 279 | // Not wanted behavior, but expected behavior: 280 | test("Internal state will reset when using onFetching", async () => { 281 | render(); 282 | await waitForElementToBeRemoved(() => 283 | screen.queryByText("Loading") 284 | ); 285 | await waitFor(() => 286 | expect(screen.getByRole("textbox")).toBeVisible() 287 | ); 288 | const input = screen.getByRole("textbox"); 289 | userEvent.type(input, "Abc{Enter}"); 290 | await waitFor(() => 291 | expect(screen.getByText("Fetching")).toBeVisible() 292 | ); 293 | await waitForElementToBeRemoved(() => 294 | screen.queryByText("Fetching") 295 | ); 296 | await waitFor(() => 297 | expect(screen.getByText("#3")).toBeVisible() 298 | ); 299 | expect(screen.getByRole("textbox")).toHaveValue(""); 300 | }); 301 | 302 | test("Can use custom loader component", async () => { 303 | const CustomLoader = (props: CustomLoaderProps) => { 304 | if (props.query.isSuccess && props.query.data) { 305 | return props.onSuccess(props.query.data); 306 | } 307 | return
Custom loader!
; 308 | }; 309 | 310 | const loader = createLoader({ 311 | config: { loaderComponent: CustomLoader }, 312 | useQueries: () => ({ 313 | queries: { 314 | charizard: useGetPokemonByNameQuery("charizard"), 315 | }, 316 | }), 317 | }); 318 | 319 | const Component = withLoader( 320 | (_, loaderData) => ( 321 |
{loaderData.queries.charizard.data.name}
322 | ), 323 | loader 324 | ); 325 | render(); 326 | expect(screen.getByText("Custom loader!")).toBeVisible(); 327 | await waitFor(() => 328 | expect(screen.getByText("charizard")).toBeVisible() 329 | ); 330 | }); 331 | 332 | test("loaderComponent is backwards compatible", async () => { 333 | const CustomLoader = (props: CustomLoaderProps) => { 334 | if (props.query.isSuccess && props.query.data) { 335 | return props.onSuccess(props.query.data); 336 | } 337 | return
Custom loader!
; 338 | }; 339 | const Component = withLoader( 340 | (_, loaderData) => ( 341 |
{loaderData.queries.charizard.data.name}
342 | ), 343 | createLoader({ 344 | loaderComponent: CustomLoader, 345 | useQueries: () => ({ 346 | queries: { 347 | charizard: useGetPokemonByNameQuery("charizard"), 348 | }, 349 | }), 350 | }) 351 | ); 352 | render(); 353 | expect(screen.getByText("Custom loader!")).toBeVisible(); 354 | await waitFor(() => 355 | expect(screen.getByText("charizard")).toBeVisible() 356 | ); 357 | }); 358 | 359 | test("Can defer some queries", async () => { 360 | const Component = withLoader( 361 | (props, { charizard, delay }) => { 362 | return ( 363 | <> 364 |
{charizard.name}
365 |
366 | {delay ? "loaded-deferred" : "loading-deferred"} 367 |
368 | 369 | ); 370 | }, 371 | createLoader({ 372 | useQueries: () => ({ 373 | queries: { 374 | charizard: useGetPokemonByNameQuery("charizard"), 375 | }, 376 | deferredQueries: { 377 | delay: useGetPokemonByNameQuery("delay"), 378 | }, 379 | }), 380 | transform: (loader) => ({ 381 | charizard: loader.queries.charizard.data, 382 | delay: loader.deferredQueries.delay.data, 383 | }), 384 | onLoading: () =>
Loading
, 385 | onError: () =>
Error
, 386 | }) 387 | ); 388 | render(); 389 | expect(screen.getByText("Loading")).toBeVisible(); 390 | await waitFor(() => 391 | expect(screen.getByText("charizard")).toBeVisible() 392 | ); 393 | expect(screen.getByText("loading-deferred")).toBeVisible(); 394 | await waitFor(() => 395 | expect(screen.getByText("loaded-deferred")).toBeVisible() 396 | ); 397 | }); 398 | 399 | test("Can defer all queries", async () => { 400 | const Component = withLoader( 401 | (props, data) => { 402 | if (data.isLoading) { 403 | return <>Loading; 404 | } 405 | return <>{data.data?.name}; 406 | }, 407 | createLoader({ 408 | useQueries: () => ({ 409 | deferredQueries: { 410 | charizard: useGetPokemonByNameQuery("charizard"), 411 | }, 412 | }), 413 | transform: (loaderData) => 414 | loaderData.deferredQueries.charizard, 415 | }) 416 | ); 417 | render(); 418 | expect(screen.getByText("Loading")).toBeVisible(); 419 | await waitFor(() => 420 | expect(screen.getByText("charizard")).toBeVisible() 421 | ); 422 | }); 423 | 424 | test("Loaders with no queries render immediately", () => { 425 | const Component = withLoader( 426 | () =>
Success
, 427 | createLoader({}) 428 | ); 429 | render(); 430 | expect(screen.getByText("Success")).toBeVisible(); 431 | }); 432 | 433 | test("Can remount a component that has a failed query", async () => { 434 | const Component = withLoader( 435 | (props, loaderData) => { 436 | return ( 437 |
438 | Success{" "} 439 | {loaderData.queries.error.data.name.includes( 440 | "charizard" 441 | )} 442 |
443 | ); 444 | }, 445 | createLoader({ 446 | queriesArg: (props: { error: boolean }) => props.error, 447 | useQueries: (shouldError) => { 448 | const error = useGetPokemonByNameQuery( 449 | shouldError ? "unprocessable" : "charizard" 450 | ); 451 | return { 452 | queries: { 453 | error, 454 | }, 455 | }; 456 | }, 457 | onError: () =>
onError
, 458 | }) 459 | ); 460 | const Wrapper = () => { 461 | const [shouldError, setShouldError] = useState(true); 462 | return ( 463 |
464 | 467 | 468 | {shouldError ? ( 469 | 470 | ) : ( 471 |
Success
472 | )} 473 |
474 |
475 | ); 476 | }; 477 | render(); 478 | await waitFor(() => 479 | expect(screen.getByText("onError")).toBeVisible() 480 | ); 481 | await userEvent.click(screen.getByRole("button")); 482 | await waitFor(() => 483 | expect(screen.getByText("Success")).toBeVisible() 484 | ); 485 | await userEvent.click(screen.getByRole("button")); 486 | await waitFor(() => 487 | expect(screen.getByText("onError")).toBeVisible() 488 | ); 489 | }); 490 | }); 491 | 492 | describe("createLoader", () => { 493 | test("Normally, deferred queries do not throw", async () => { 494 | const Component = withLoader( 495 | (props, data) => { 496 | return
Hello
; 497 | }, 498 | createLoader({ 499 | useQueries: () => ({ 500 | deferredQueries: { 501 | charizard: useGetPokemonByNameQuery("error"), 502 | }, 503 | }), 504 | }) 505 | ); 506 | render(); 507 | await waitFor( 508 | () => new Promise((res) => setTimeout(res, 200)) 509 | ); 510 | expect(screen.getByText("Hello")).toBeVisible(); 511 | }); 512 | 513 | test("Deferred queries throw error when configured to", async () => { 514 | const Component = withLoader( 515 | (props, data) => { 516 | return
Hello
; 517 | }, 518 | createLoader({ 519 | useQueries: () => ({ 520 | deferredQueries: { 521 | charizard: useGetPokemonByNameQuery("error"), 522 | }, 523 | }), 524 | config: { 525 | deferred: { 526 | shouldThrowError: true, 527 | }, 528 | }, 529 | onError: () =>
Error
, 530 | }) 531 | ); 532 | render(); 533 | await waitFor( 534 | () => new Promise((res) => setTimeout(res, 200)) 535 | ); 536 | expect(screen.getByText("Error")).toBeVisible(); 537 | }); 538 | 539 | test("Can send static payload to loader", async () => { 540 | const Component = withLoader( 541 | (_, loader) => { 542 | return
{loader.payload.foo}
; 543 | }, 544 | createLoader({ 545 | useQueries: () => ({ 546 | payload: { 547 | foo: "bar" as const, 548 | }, 549 | }), 550 | }) 551 | ); 552 | render(); 553 | expect(screen.getByText("bar")).toBeVisible(); 554 | }); 555 | 556 | test("Loader passes props through queriesArg to queries", async () => { 557 | render(); 558 | await waitFor(() => 559 | expect( 560 | screen.getByText( 561 | 'Loaded: "charizard", props: "charizard"' 562 | ) 563 | ).toBeVisible() 564 | ); 565 | }); 566 | 567 | describe(".extend()", () => { 568 | test("Can extend onLoading", async () => { 569 | render(); 570 | expect(screen.getByText("Extended loading")).toBeVisible(); 571 | }); 572 | 573 | test("Can extend onError", async () => { 574 | const Component = withLoader( 575 | (props, loaderData) => { 576 | return
Success
; 577 | }, 578 | 579 | createLoader({ 580 | useQueries: () => { 581 | const error = useGetPokemonByNameQuery("error"); 582 | return { 583 | queries: { 584 | error, 585 | }, 586 | }; 587 | }, 588 | onLoading: () =>
Loading
, 589 | onError: () =>
Error
, 590 | }).extend({ 591 | onError: () =>
Extended Error
, 592 | }) 593 | ); 594 | render(); 595 | expect(screen.getByText("Loading")).toBeVisible(); 596 | await waitFor(() => 597 | expect(screen.getByText("Extended Error")).toBeVisible() 598 | ); 599 | }); 600 | 601 | test("Can extend onFetching", async () => { 602 | const loader = createLoader({ 603 | useQueries: (arg) => ({ 604 | queries: { 605 | pokemon: useGetPokemonByNameQuery(arg), 606 | }, 607 | }), 608 | queriesArg: (props: { 609 | name: string; 610 | onChange: (name: string) => void; 611 | }) => props.name, 612 | onLoading: () =>
Loading
, 613 | onFetching: () =>
Fetching
, 614 | }).extend({ 615 | onFetching: () =>
Extended Fetching
, 616 | }); 617 | 618 | const Component = withLoader((props, loaderData) => { 619 | return ( 620 |
621 | Success{" "} 622 | {loaderData.queries.pokemon.data.name} 623 | 628 |
629 | ); 630 | }, loader); 631 | 632 | const Controller = () => { 633 | const [name, setName] = useState("a"); 634 | return ; 635 | }; 636 | 637 | render(); 638 | expect(screen.getByText("Loading")).toBeVisible(); 639 | await waitFor(() => 640 | expect(screen.getByRole("button")).toBeVisible() 641 | ); 642 | await userEvent.click(screen.getByRole("button")); 643 | await waitFor(() => 644 | expect( 645 | screen.getByText("Extended Fetching") 646 | ).toBeVisible() 647 | ); 648 | }); 649 | 650 | test("Can extend whileFetching", async () => { 651 | const loader = createLoader({ 652 | useQueries: (arg: string) => ({ 653 | queries: { 654 | pokemon: useGetPokemonByNameQuery(arg), 655 | }, 656 | }), 657 | queriesArg: (props: { 658 | name: string; 659 | onChange: (name: string) => void; 660 | }) => props.name, 661 | onLoading: () =>
Loading
, 662 | whileFetching: { 663 | prepend: () =>
Fetching
, 664 | }, 665 | }).extend({ 666 | whileFetching: { 667 | prepend: () => Extended Fetching, 668 | }, 669 | }); 670 | expect(loader.whileFetching?.prepend).not.toBeUndefined(); 671 | 672 | const Component = withLoader((props, loaderData) => { 673 | return ( 674 |
675 | Success{" "} 676 | {loaderData.queries.pokemon.data.name} 677 | 682 |
683 | ); 684 | }, loader); 685 | 686 | const Controller = () => { 687 | const [name, setName] = useState("a"); 688 | return ; 689 | }; 690 | 691 | render(); 692 | expect(screen.getByText("Loading")).toBeVisible(); 693 | await waitFor(() => 694 | expect(screen.getByRole("button")).toBeVisible() 695 | ); 696 | await userEvent.click(screen.getByRole("button")); 697 | await waitFor(() => 698 | expect( 699 | screen.queryByText(/extended fetching/i) 700 | ).toBeVisible() 701 | ); 702 | }); 703 | 704 | test("Can extend queries", async () => { 705 | const loader = createLoader({ 706 | useQueries: (arg: string) => ({ 707 | queries: { 708 | pokemon: useGetPokemonByNameQuery(arg), 709 | }, 710 | }), 711 | queriesArg: (props: { name: string }) => props.name, 712 | onLoading: () =>
Loading
, 713 | }).extend({ 714 | useQueries: (arg: string) => ({ 715 | queries: { 716 | pokemon: useGetPokemonByNameQuery(arg), 717 | pokemons: useGetPokemonsQuery(undefined), 718 | }, 719 | }), 720 | }); 721 | 722 | const Component = withLoader((props, loaderData) => { 723 | return ( 724 |
725 |
    726 |
  • {loaderData.queries.pokemon.data.name}
  • 727 | {loaderData.queries.pokemons.data.results.map( 728 | (pokemon) => ( 729 |
  • {pokemon.name}
  • 730 | ) 731 | )} 732 |
733 |
734 | ); 735 | }, loader); 736 | 737 | render(); 738 | expect(screen.getByText("Loading")).toBeVisible(); 739 | await waitFor(() => 740 | expect(screen.getByText("test")).toBeVisible() 741 | ); 742 | expect(screen.getByText(/charizard/i)).toBeVisible(); 743 | expect(screen.getByText(/pikachu/i)).toBeVisible(); 744 | }); 745 | 746 | test("Can extend deferred queries", async () => { 747 | const Component = withLoader( 748 | (props, { charizard, delay }) => { 749 | return ( 750 | <> 751 |
{charizard.name}
752 |
753 | {delay ? "loaded-deferred" : "loading-deferred"} 754 |
755 | 756 | ); 757 | }, 758 | createLoader({ 759 | useQueries: () => ({ 760 | queries: { 761 | pokemons: useGetPokemonsQuery(undefined), 762 | }, 763 | deferredQueries: { 764 | charizard: useGetPokemonByNameQuery("charizard"), 765 | }, 766 | }), 767 | }).extend({ 768 | useQueries: () => ({ 769 | queries: { 770 | charizard: useGetPokemonByNameQuery("charizard"), 771 | }, 772 | deferredQueries: { 773 | delay: useGetPokemonByNameQuery("delay"), 774 | }, 775 | }), 776 | transform: (loader) => ({ 777 | charizard: loader.queries.charizard.data, 778 | delay: loader.deferredQueries.delay.data, 779 | }), 780 | onLoading: () =>
Loading
, 781 | onError: () =>
Error
, 782 | }) 783 | ); 784 | render(); 785 | expect(screen.getByText("Loading")).toBeVisible(); 786 | await waitFor(() => 787 | expect(screen.getByText("charizard")).toBeVisible() 788 | ); 789 | expect(screen.getByText("loading-deferred")).toBeVisible(); 790 | await waitFor(() => 791 | expect(screen.getByText("loaded-deferred")).toBeVisible() 792 | ); 793 | }); 794 | 795 | test("Can extend many times", async () => { 796 | const Component = withLoader( 797 | (props, loader) => { 798 | return ( 799 |
800 |
{loader.queries.pokemon.data.name}
801 |
{loader.foobar}
802 | 807 |
808 | ); 809 | }, 810 | createLoader({ 811 | onLoading: () =>
Loading
, 812 | }) 813 | .extend({ 814 | useQueries: () => { 815 | const [name, setName] = useState("charizard"); 816 | return { 817 | queries: { 818 | pokemon: useGetPokemonByNameQuery(name), 819 | }, 820 | payload: { 821 | name, 822 | setName, 823 | }, 824 | }; 825 | }, 826 | }) 827 | .extend({ 828 | onLoading: () =>
Extended Loading One
, 829 | }) 830 | .extend({ 831 | onError: () =>
Extended Error
, 832 | }) 833 | .extend({ 834 | transform: (data) => ({ ...data, foobar: "foobar" }), 835 | }) 836 | .extend({ 837 | onLoading: () =>
Extended Loading Two
, 838 | }) 839 | ); 840 | 841 | render(); 842 | expect( 843 | screen.getByText("Extended Loading Two") 844 | ).toBeVisible(); 845 | await waitFor(() => 846 | expect(screen.getByText("charizard")).toBeVisible() 847 | ); 848 | expect(screen.getByText("foobar")).toBeVisible(); 849 | await userEvent.click(screen.getByRole("button")); 850 | await waitFor(() => 851 | expect(screen.getByText("Extended Error")).toBeVisible() 852 | ); 853 | }); 854 | 855 | test("Can extend with only transform", async () => { 856 | const Component = withLoader( 857 | (props, pokemon) => { 858 | return
{pokemon.name}
; 859 | }, 860 | createLoader({ 861 | useQueries: () => ({ 862 | queries: { 863 | pokemon: useGetPokemonByNameQuery("charizard"), 864 | }, 865 | }), 866 | }).extend({ 867 | transform: (data) => data.queries.pokemon.data, 868 | }) 869 | ); 870 | render(); 871 | await waitFor(() => 872 | expect(screen.getByText("charizard")).toBeVisible() 873 | ); 874 | }); 875 | 876 | test("Can partially extend config", async () => { 877 | const CustomLoader = (props: CustomLoaderProps) => { 878 | if (props.query.isError) { 879 | return
Custom error!
; 880 | } 881 | if (props.query.isSuccess && props.query.data) { 882 | return props.onSuccess(props.query.data); 883 | } 884 | return
Custom loader!
; 885 | }; 886 | 887 | const loader = createLoader({ 888 | config: { 889 | loaderComponent: CustomLoader, 890 | deferred: { shouldThrowError: true }, 891 | }, 892 | useQueries: () => ({ 893 | queries: { 894 | charizard: useGetPokemonByNameQuery("charizard"), 895 | }, 896 | deferredQueries: { 897 | charizard: useGetPokemonByNameQuery("error"), 898 | }, 899 | }), 900 | }).extend({ 901 | config: { 902 | deferred: { shouldThrowError: false }, 903 | }, 904 | }); 905 | 906 | const Component = withLoader( 907 | (_, loaderData) => ( 908 |
{loaderData.queries.charizard.data.name}
909 | ), 910 | loader 911 | ); 912 | render(); 913 | 914 | // We expect that the custom loader is rendered, 915 | // But that the deferredQuery does not cause component to render error view 916 | expect(screen.getByText("Custom loader!")).toBeVisible(); 917 | await waitFor(() => 918 | expect(screen.getByText("charizard")).toBeVisible() 919 | ); 920 | }); 921 | }); 922 | }); 923 | -------------------------------------------------------------------------------- /testing-app/src/utils.tsx: -------------------------------------------------------------------------------- 1 | import { render, RenderOptions } from "@testing-library/react"; 2 | import React, { FC, ReactElement } from "react"; 3 | import { Provider } from "react-redux"; 4 | import { store } from "./store"; 5 | 6 | const AllTheProviders: FC<{ children: React.ReactNode }> = ({ 7 | children, 8 | }) => { 9 | return {children}; 10 | }; 11 | 12 | const customRender = ( 13 | ui: ReactElement, 14 | options?: Omit 15 | ) => render(ui, { wrapper: AllTheProviders, ...options }); 16 | 17 | // re-export everything 18 | export * from "@testing-library/react"; 19 | // override render method 20 | export { customRender as render }; 21 | -------------------------------------------------------------------------------- /testing-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "react": ["./node_modules/@types/react"] 20 | } 21 | }, 22 | "include": ["src", "jest.polyfills.js"] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["dist", "node_modules", "docs"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "outDir": "./dist/esm", 12 | "strict": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "moduleResolution": "node", 18 | "jsx": "react", 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "paths": { 23 | "react": ["./node_modules/@types/react"] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@reduxjs/toolkit@^2.2.5": 6 | version "2.2.5" 7 | resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.5.tgz#c0d2d8482ef80722bebe015ff05b06c34bfb6e0d" 8 | integrity sha512-aeFA/s5NCG7NoJe/MhmwREJxRkDs0ZaSqt0MxhWUrwCf1UQXpwR87RROJEql0uAkLI6U7snBOYOcKw83ew3FPg== 9 | dependencies: 10 | immer "^10.0.3" 11 | redux "^5.0.1" 12 | redux-thunk "^3.1.0" 13 | reselect "^5.1.0" 14 | 15 | "@types/prop-types@*": 16 | version "15.7.12" 17 | resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz" 18 | integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== 19 | 20 | "@types/react-dom@^18.2.15": 21 | version "18.3.0" 22 | resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0" 23 | integrity sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg== 24 | dependencies: 25 | "@types/react" "*" 26 | 27 | "@types/react@*", "@types/react@^18.2.37": 28 | version "18.3.3" 29 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" 30 | integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== 31 | dependencies: 32 | "@types/prop-types" "*" 33 | csstype "^3.0.2" 34 | 35 | csstype@^3.0.2: 36 | version "3.1.3" 37 | resolved "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz" 38 | integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== 39 | 40 | immer@^10.0.3: 41 | version "10.1.1" 42 | resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" 43 | integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== 44 | 45 | "js-tokens@^3.0.0 || ^4.0.0": 46 | version "4.0.0" 47 | resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" 48 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 49 | 50 | loose-envify@^1.1.0: 51 | version "1.4.0" 52 | resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" 53 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 54 | dependencies: 55 | js-tokens "^3.0.0 || ^4.0.0" 56 | 57 | react@^18.2.0: 58 | version "18.3.1" 59 | resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" 60 | integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== 61 | dependencies: 62 | loose-envify "^1.1.0" 63 | 64 | redux-thunk@^3.1.0: 65 | version "3.1.0" 66 | resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" 67 | integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== 68 | 69 | redux@^5.0.1: 70 | version "5.0.1" 71 | resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" 72 | integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== 73 | 74 | reselect@^5.1.0: 75 | version "5.1.0" 76 | resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.0.tgz#c479139ab9dd91be4d9c764a7f3868210ef8cd21" 77 | integrity sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg== 78 | 79 | tslib@^2.4.0: 80 | version "2.6.2" 81 | resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz" 82 | integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== 83 | 84 | typescript@^5.4.5: 85 | version "5.4.5" 86 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" 87 | integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== 88 | --------------------------------------------------------------------------------