docs: add dynamic, persisting, lazy InfiniteQueries
This commit is contained in:
@@ -1,182 +0,0 @@
|
||||
---
|
||||
title: Infinite Queries
|
||||
sidebar_position: 11
|
||||
---
|
||||
|
||||
|
||||
Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. Fl Query supports a useful version of `Query` called `InfiniteQuery` for querying these types of lists.
|
||||
|
||||
When using `InfiniteQueryBuilder`, you'll notice a few things are different:
|
||||
|
||||
- `data` is now an object containing infinite query data as `Map<type of page parameter, type of page data>`
|
||||
- `data.pages` List containing the fetched pages
|
||||
- `data.pageParams` List containing the page params used to fetch the pages
|
||||
- The `fetchNextPage` and `fetchPreviousPage` methods are now available
|
||||
- The `getNextPageParam` and `getPreviousPageParam` options are available for both determining if there is more data to load and the information to fetch it. This information is supplied as an additional parameter in the query function (which can optionally be overridden when calling the `fetchNextPage` or `fetchPreviousPage` methods)
|
||||
- A `hasNextPage` boolean is now available and is `true` if `getNextPageParam` returns a value other than `false`
|
||||
- A `hasPreviousPage` boolean is now available and is `true` if `getPreviousPageParam` returns a value other than `false`
|
||||
- The `isFetchingNextPage` and `isFetchingPreviousPage` booleans are now available to distinguish between a background refresh state and a loading more state
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
Let's assume we have an API that returns pages of `projects` 3 at a time based on a `cursor` index along with a cursor that can be used to fetch the next group of projects:
|
||||
|
||||
```dart
|
||||
http.get('$hostUrl/api/projects?cursor=0');
|
||||
// { data: [...], nextCursor: 3}
|
||||
http.get('$hostUrl/api/projects?cursor=3');
|
||||
// { data: [...], nextCursor: 6}
|
||||
http.get('$hostUrl/api/projects?cursor=6');
|
||||
// { data: [...], nextCursor: 9}
|
||||
http.get('$hostUrl/api/projects?cursor=9');
|
||||
// { data: [...] }
|
||||
```
|
||||
|
||||
With this information, we can create a "Load More" UI by:
|
||||
|
||||
- Waiting for `InfiniteQuery` to request the first group of data by default
|
||||
- Returning the information for the next query in `getNextPageParam`
|
||||
- Calling `fetchNextPage` function
|
||||
|
||||
> Note: It's very important you do not call `fetchNextPage` with arguments unless you want them to override the `pageParam` data returned from the `getNextPageParam` function
|
||||
|
||||
```dart
|
||||
import "packages:fl_query/fl_query.dart";
|
||||
import "package:http/http.dart" as http;
|
||||
|
||||
final projectsJob = InfiniteQueryJob<Map<String, dynamic>, void, int>(
|
||||
queryKey: 'projects',
|
||||
initialParam: 0,
|
||||
getNextPageParam: (lastPage, pages) => lastPage['nextCursor'],
|
||||
getPreviousPageParam: (currentPage, pages) => currentPage['previousCursor'],
|
||||
task: (queryKey, pageParam, externalData){
|
||||
return http.get('$hostUrl/api/projects?cursor=$pageParam');
|
||||
},
|
||||
);
|
||||
|
||||
class Projects extends StatelessWidget{
|
||||
Project({super.key});
|
||||
|
||||
@override
|
||||
build(context){
|
||||
return InfiniteQueryBuilder(
|
||||
job: projectsJob,
|
||||
builder: (context, query){
|
||||
if(query.isLoading){
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if(query.isError){
|
||||
return Center(child: Text('Error: ${query.error}'));
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
itemCount: query.pages.length,
|
||||
itemBuilder: (context, index){
|
||||
final project = query.pages[index];
|
||||
return ListTile(title: Text(project['name']));
|
||||
}
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.get_app_rounded),
|
||||
onPressed: query.isFetchingNextPage || !query.hasNextPage
|
||||
? null
|
||||
: () => query.fetchNextPage(),
|
||||
),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What happens when an infinite query needs to be refetched?
|
||||
|
||||
When an infinite query becomes `stale` and needs to be refetched, each group is fetched `sequentially`, starting from the first one. This ensures that even if the underlying data is mutated, we're not using stale cursors and potentially getting duplicates or skipping records. If an infinite query's results are ever removed from the QueryBowl's Cache, the pagination restarts at the initial state with only the initial group being requested.
|
||||
|
||||
### refetchPage
|
||||
|
||||
If you only want to actively refetch a subset of all pages, you can use the `refetchPage` method of `InfiniteQuery`. It optionally takes a `selector callback` to programmatically choose which pages to refetch. If no selector is provided, all pages will be refetched sequentially.
|
||||
|
||||
```dart
|
||||
// refetching all the pages
|
||||
infiniteQuery.refetchPages();
|
||||
|
||||
// refetching custom selected pages
|
||||
infiniteQuery.refetchPages((page, pageParam, allPages){
|
||||
// this will refetch all the pages that are fetched after the 10th page
|
||||
return pageParam > 10;
|
||||
})
|
||||
```
|
||||
|
||||
## What if I need to pass custom page parameter to my `fetchNextPage` function?
|
||||
|
||||
By default, the variable returned from `getNextPageParam` will be supplied to the task function, but in some cases, you may want to override this. You can pass custom `getNextPageParam` to the `fetchNextPage` method only for that very call which will override the default variable like so:
|
||||
|
||||
```dart
|
||||
infiniteQuery.fetchNextPage((lastPage, lastParam)=> 20)
|
||||
```
|
||||
|
||||
|
||||
## Manually update the infinite query data
|
||||
|
||||
Manually removing first page:
|
||||
|
||||
```dart
|
||||
QueryBowl.of(context)
|
||||
.setQueryData(exampleInfiniteQueryJob.queryKey, (oldData){
|
||||
oldData?.remove(0);
|
||||
return Map.from(oldData ?? {});
|
||||
})
|
||||
```
|
||||
|
||||
Manually removing a single value from an individual page:
|
||||
|
||||
```dart
|
||||
QueryBowl.of(context)
|
||||
.setQueryData(exampleInfiniteQueryJob.queryKey, (oldData){
|
||||
oldData?.removeWhere((key, value){
|
||||
return value["id"] != someOtherValue["id"];
|
||||
});
|
||||
return Map.from(oldData ?? {});
|
||||
})
|
||||
```
|
||||
|
||||
## Infinite Query with Dynamic queryKey
|
||||
|
||||
Just like regular [`QueryJob`](/docs/basics/DynamicQueries), `InfiniteQueryJob` also supports dynamic queryKeys via the `InfiniteQuery.withVariableKey` static method. This is useful when your API/source of data returns the same structure of data for multiple endpoints e.g dynamic routes.
|
||||
|
||||
```dart
|
||||
final projectsJob = InfiniteQueryJob.withVariableKey<Map<String, dynamic>, void, int>(
|
||||
queryKey: (queryKey) => 'projects-$queryKey',
|
||||
initialParam: 0,
|
||||
getNextPageParam: (lastPage, pages) => lastPage['nextCursor'],
|
||||
getPreviousPageParam: (currentPage, pages) => currentPage['previousCursor'],
|
||||
task: (queryKey, pageParam, externalData){
|
||||
final projectId = getVariable(queryKey);
|
||||
return http.get('$hostUrl/api/projects/$projectId/?cursor=$pageParam');
|
||||
},
|
||||
);
|
||||
|
||||
// using the same query function for multiple queries
|
||||
InfiniteQueryBuilder(
|
||||
job: projectsJob.withQueryKey('1'),
|
||||
builder: (context, query){
|
||||
// ...
|
||||
}
|
||||
)
|
||||
|
||||
InfiniteQueryBuilder(
|
||||
job: projectsJob.withQueryKey('2'),
|
||||
builder: (context, query){
|
||||
// ...
|
||||
}
|
||||
)
|
||||
```
|
||||
@@ -0,0 +1,364 @@
|
||||
---
|
||||
title: Infinite Queries
|
||||
sidebar_position: 11
|
||||
---
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
|
||||
Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. Fl Query supports a useful version of `Query` called `InfiniteQuery` for querying these types of lists.
|
||||
|
||||
## Create an InfiniteQuery
|
||||
|
||||
The `InfiniteQueryBuilder`/`useInfiniteQuery` is used to create InfiniteQueries. It's almost same as [`QueryBuilder and useQuery`](/docs/basics/Queries#create-a-query)
|
||||
|
||||
Here's how to create one:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="vanilla" label="Vanilla">
|
||||
|
||||
```dart
|
||||
InfiniteQueryBuilder<PagedProducts, ClientException, int>(
|
||||
"products",
|
||||
(page) => api.getProductsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
builder: /*...*/
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="flutter_hooks" label="Flutter Hooks">
|
||||
|
||||
```dart
|
||||
final query = useInfiniteQuery<PagedProducts, ClientException, int>(
|
||||
"products",
|
||||
(page) => api.getProductsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
InfiniteQuery has some required parameters:
|
||||
|
||||
- `key`(unnamed)
|
||||
- `queryFn`(unnamed)
|
||||
- `nextPage` - A function that returns the next page number or `null` if there are no more pages.
|
||||
- `initialPage` - The initial page to start from.
|
||||
|
||||
All the Type parameters of both `InfiniteQueryBuilder` and `useInfiniteQuery` might seem overwhelming but using these makes your code more type safe and easier to understand. So the type parameters are:
|
||||
|
||||
- `<DataType>` - The type of data returned by the `queryFn`
|
||||
- `<ErrorType>` - The type of error returned by the `queryFn`
|
||||
- `<PageType>` - The type of page
|
||||
|
||||
|
||||
:::note
|
||||
Make sure to return `null` for `nextPage` to indicate there's no more pages to load.
|
||||
:::
|
||||
|
||||
## InfiniteQuery
|
||||
|
||||
An `InfiniteQuery` will passed/returned by the `InfiniteQueryBuilder`/`useInfiniteQuery` which can used to manipulate the InfiniteQuery
|
||||
|
||||
### States
|
||||
|
||||
Just like `Query` an `InfiniteQuery` has 2 groups of states: 1. Progressive States 2. Data availability States
|
||||
|
||||
- Progressive States
|
||||
- `isLoadingNextPage` - `true` if the next page is currently loading.
|
||||
- `isRefreshingPage` - `true` if the current page is currently refreshing.
|
||||
- `isInactive` - `true` if the query is not fetching and has no errors and has no listeners
|
||||
- Data availability states
|
||||
- `hasNextPage` - `true` if there is a next page to fetch.
|
||||
- `hasPages` - `true` if there are pages available.
|
||||
- `hasErrors` - `true` if there are errors in any pages.
|
||||
- `hasPageData` - `true` if data is available for the current page.
|
||||
- `hasPageError` - `true` if there's an error in the current page.
|
||||
|
||||
Here's an example of how these states can be used to render a paginated list:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="vanilla" label="Vanilla">
|
||||
|
||||
```dart
|
||||
/// Inside the [builder] of previous example
|
||||
final products = query.pages.map((e) => e.products).expand((e) => e);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
for (final product in products)
|
||||
ListTile(
|
||||
title: Text(product.title),
|
||||
subtitle: Text(product.description),
|
||||
leading: Image.network(product.thumbnail),
|
||||
),
|
||||
if (query.hasNextPage && query.isLoadingNextPage)
|
||||
ElevatedButton(
|
||||
onPressed: () => query.fetchNext(),
|
||||
child: Text("Load More"),
|
||||
)
|
||||
else if (query.hasNextPage && !query.isLoadingNextPage)
|
||||
ElevatedButton(
|
||||
onPressed: null,
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
if (query.hasErrors)
|
||||
...query.errors.map((e) => Text(e.message)).toList(),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="flutter_hooks" label="Flutter Hooks">
|
||||
|
||||
```dart
|
||||
/// Using the [query] from previous example
|
||||
final products = useMemoized(
|
||||
() => query.pages.map((e) => e.products).expand((e) => e),
|
||||
[query.pages],
|
||||
);
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
for (final product in products)
|
||||
ListTile(
|
||||
title: Text(product.title),
|
||||
subtitle: Text(product.description),
|
||||
leading: Image.network(product.thumbnail),
|
||||
),
|
||||
if (query.hasNextPage && query.isLoadingNextPage)
|
||||
ElevatedButton(
|
||||
onPressed: () => query.fetchNext(),
|
||||
child: Text("Load More"),
|
||||
)
|
||||
else if (query.hasNextPage && !query.isLoadingNextPage)
|
||||
ElevatedButton(
|
||||
onPressed: null,
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
if (query.hasErrors)
|
||||
...query.errors.map((e) => Text(e.message)).toList(),
|
||||
],
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Fetching next page
|
||||
|
||||
`InfiniteQuery` has `hasNextPage` that must be used to check if there's any pages left to fetch. The `fetchNext` can be used to fetch the next page.
|
||||
|
||||
```dart
|
||||
if (query.hasNextPage){
|
||||
await query.fetchNext();
|
||||
}
|
||||
```
|
||||
|
||||
Also `InfiniteQuery.isLoadingNextPage` can be used to show a loading indicator while the next page is loading.
|
||||
|
||||
```dart
|
||||
ListView(
|
||||
children: [
|
||||
// ...
|
||||
if (query.hasNextPage && query.isLoadingNextPage)
|
||||
CircularProgressIndicator(),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Refreshing
|
||||
|
||||
`InfiniteQuery` uses pages to store data and each individual page are fetched/refreshed in a sequeunce. `InfiniteQuery` provides two methods `InfiniteQuery.refresh` and `InfiniteQuery.refreshAll` to refresh once page or all pages at once.
|
||||
|
||||
Refresh a current page:
|
||||
|
||||
```dart
|
||||
await query.refresh();
|
||||
```
|
||||
|
||||
Passing no page argument will refresh the current page by default.
|
||||
|
||||
Refresh a specific page:
|
||||
|
||||
```dart
|
||||
await query.refresh(2);
|
||||
```
|
||||
|
||||
Refresh all pages:
|
||||
|
||||
```dart
|
||||
await query.refreshAll();
|
||||
```
|
||||
|
||||
:::note
|
||||
Refreshing all pages can be really expensive and should be done with caution.
|
||||
:::
|
||||
|
||||
If you need refresh specific pages or to refresh some segments, you can just combine `refresh` with `Future.wait` or just a plain for loop.
|
||||
|
||||
```dart
|
||||
await Future.wait(
|
||||
[1, 2, 3].map((e) => query.refresh(e)),
|
||||
);
|
||||
```
|
||||
|
||||
### Set page data manually
|
||||
|
||||
`InfiniteQuery` provides a method `InfiniteQuery.setPageData` to set page data manually. This can be useful if you want to set data after a mutation for [Optmisitc Updates](/docs/basics/OptimisticUpdates)
|
||||
|
||||
```dart
|
||||
query.setPageData(0, [...query.pages[0], newProduct])
|
||||
```
|
||||
You can use `InfiniteQuery.pages.map` to set page data for all pages.
|
||||
|
||||
:::note
|
||||
If the specified page doesn't exist, `setPageData` will create a new page and add the data to it.
|
||||
:::
|
||||
|
||||
### Dynamic Key
|
||||
|
||||
Just like [`Query`](/docs/basics/queries#dynamic-key) with dart's String interpolation, you can pass dynamic keys to the InfiniteQuery. This will create new instance of InfiniteQuery for every dynamically generated unique key
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="vanilla" label="Vanilla">
|
||||
|
||||
```dart
|
||||
InfiniteQueryBuilder<PagedProducts, ClientException, int>(
|
||||
"category/$categoryId/products",
|
||||
(page) => api.getProductsPaginated(page, categoryId),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
builder: /*...*/
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="flutter_hooks" label="Flutter Hooks">
|
||||
|
||||
```dart
|
||||
final query = useInfiniteQuery<PagedProducts, ClientException, int>(
|
||||
"category/$categoryId/products",
|
||||
(page) => api.getProductsPaginated(page, categoryId),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Lazy InfiniteQuery
|
||||
|
||||
Just like [Query](/docs/basics/Queries#lazy-query) by default InfiniteQueries are executed immediately after they are mounted. But you can also make them lazy by passing `enabled: false` to the `InfiniteQueryBuilder` or `useInfiniteQuery`
|
||||
Until `InfiniteQuery.fetch` or `InfiniteQuery.refresh` is called, anything won't be fetched
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="vanilla" label="Vanilla">
|
||||
|
||||
```dart
|
||||
InfiniteQueryBuilder<PagedProducts, ClientException, int>(
|
||||
"lazy-products",
|
||||
(page) => api.getProductsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
enabled: false,
|
||||
builder: /*...*/
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="flutter_hooks" label="Flutter Hooks">
|
||||
|
||||
```dart
|
||||
final query = useInfiniteQuery<PagedProducts, ClientException, int>(
|
||||
"lazy-products",
|
||||
(page) => api.getProductsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.products.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
enabled: false,
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Persisting InfiniteQueries
|
||||
|
||||
The `pages` data can be saved to disk to be loaded next time when the app starts to reduce loading time of your app. Just like [`Query`](/docs/basics/Queries#persisting-queries) persisted `InfiniteQuery`'s `pages` data are stored in [hive](https://docs.hivedb.dev/#/). This can be widely useful to add offline capabilities to the app
|
||||
|
||||
For this you need to pass a `jsonConfig: JsonConfig()` argument to `InfiniteQueryBuilder` or `useInfiniteQuery`. Also make sure your `DataType` is serializable to JSON. You can use [json_serializable](https://pub.dev/packages/json_serializable) to generate the code for you.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="vanilla" label="Vanilla">
|
||||
|
||||
```dart
|
||||
InfiniteQueryBuilder<PagedPosts, ClientException, int>(
|
||||
"posts",
|
||||
(page) => api.getPostsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.posts.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
jsonConfig: JsonConfig(
|
||||
fromJson: (json)=> PagedPosts.fromJson(json),
|
||||
toJson: (pagedPosts) => pagedPosts.toJson(),
|
||||
),
|
||||
builder: /*...*/
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="flutter_hooks" label="Flutter Hooks">
|
||||
|
||||
```dart
|
||||
final query = useInfiniteQuery<PagedPosts, ClientException, int>(
|
||||
"posts",
|
||||
(page) => api.getPostsPaginated(page),
|
||||
nextPage: (lastPage, lastPageData) {
|
||||
/// returning [null] will set [hasNextPage] to [false]
|
||||
if (lastPageData.posts.length < 10) return null;
|
||||
return lastPage + 1;
|
||||
},
|
||||
initialPage: 0,
|
||||
jsonConfig: JsonConfig(
|
||||
fromJson: (json)=> PagedPosts.fromJson(json),
|
||||
toJson: (pagedPosts) => pagedPosts.toJson(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
This will persist each available page to a HiveStore
|
||||
@@ -6,7 +6,7 @@ sidebar_position: 6
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
## MutationBuilder
|
||||
## Create a mutation
|
||||
|
||||
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects. You can use `MutationBuilder` or `useMutation` to create a mutation.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ sidebar_position: 3
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
## QueryBuilder and useQuery
|
||||
## Create a Query
|
||||
|
||||
Most of the time you'll be using `QueryBuilder`/`useQuery` to create & manipulate Queries.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user