Fl-Query⚡ for Flutter
Managing async data & mutations in Flutter is like a breeze now🌬️
If you’re familiar with Flutter, you’re definitely familiar with FutureBuilder
. After all this is the only way you can fetch data in Flutter with proper state, or is it? But you might’ve noticed every time the Widget gets rebuild the Future is run along too unless you declare your Future
in initState
But even if you declare your future in initState
to avoid reruns across rebuilds it will still rerun when the Widget gets disposed & mounted again. So what’s the solution? Ans: FutureProvider
from riverpod or provider
Hold on a sec, but it’s not another riverpod/provider article? No, it's not.
Yeah, to cache your future results or server response in you can use FutureProvider
. It can store & distribute the result all across the application without multiple reruns of the same operation. But what happens when the Data becomes expired or stale? What if your app won’t need that data after some time but the data still wasting RAM?
This is where Fl-Query comes to play. It’s an Async Data + Mutation Manager for Flutter that caches, fetches, automatically refetches stale data. Similar to Tanstack Query in the World of web development, but only the concept is implemented & The API is very similar to what Flutter Developers are used to & so it makes everyone feel like home
Enough talk let’s jump to the big part.
What does it offer?
- Async data caching & invalidation
- Smart & highly configurable refetch mechanism that smartly updates stale/expired data in the background when needed
- Declarative way to define asynchronous operations
- Garbage Collects Query & Mutation. That means unused queries sitting in the Cache for long time gets automatically removed
- Code & data reusability because of persisted data & Query/Mutation Job API
- Optimistic updates
- Lazy Loading Queries. Run you defined asynchronous task or operation when needed
- Zero Configuration out of the box Global Store that no one ever have to touch
- Supports both Vanilla Flutter & Flutter Hooks
Let’s see some Code
// A QueryJob is where the Logic of how the data should be
// fetched can defined. The task callback is a PURE Function
// & have access to external resources through the second
// parameter where the first parameter is the queryKey
final successJob = QueryJob<String, void>(
queryKey: "query-example",
task: (queryKey, externalData) => Future.delayed(
const Duration(seconds: 2),
() =>
"The work successfully executed. Data: key=($queryKey) value=${
Random.secure().nextInt(100)
}",
),
);
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// QueryBowlScope creates a Bowl (metaphor for Collection/Store)
// for all the Queries & Mutations
return QueryBowlScope(
child: MaterialApp(
title: 'Fl-Query Quick Start',
theme: ThemeData(
useMaterial3: true,
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
),
);
}
}
class BasicExample extends StatelessWidget {
const BasicExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"# Basic Query Example",
style: Theme.of(context).textTheme.headline5,
),
// QueryBuilder Widget provides the expected query
// instances through the builder callback based on
// the passed job & externalData argument
QueryBuilder<String, void>(
job: successJob,
externalData: null,
builder: (context, query) {
if (!query.hasData || query.isLoading || query.isRefetching) {
return const CircularProgressIndicator();
}
return Row(
children: [
Text(query.data!),
ElevatedButton(
child: const Text("Refetch"),
onPressed: () async {
await query.refetch();
},
), // Text
],
); // Row
},
), // QueryBuilder
],
);
}
}
Here’s three key important part in the code
QueryBowlScope
: It's the store where all queries & mutations are stored and distributed throughout the applicationQueryJob
: This what one can use to declare and define the asynchronous operations that retrieves data from server or any other placeQueryBuilder
: It creates appropriateQuery
by using the providedjob
& provides theQuery
in the builder method. It is the way the data & the UI connects
QueryBowlScope
& QueryJob
has many properties that can be configured & changed in a way that best suits your application. Most of their properties are some. But properties defined in QueryBowlScope
are global configuration where QueryJob
is specific to that very Query
Query External Data
One thing you might’ve notice or have been thinking that the task function is separated from the Widget so how would you use other services, classes, methods or data in it. Simply, how will give inputs to a Query Task or the asynchronous operation? This is where the second parameter of QueryJob
’s task callback comes to play. You can use the QueryBuilder
’s externalData
named argument to pass data to the task callback. You can literally use anything as externalData. Just pass the Type as Type Parameter to QueryJob
& QueryBuilder
QueryJob with externalData example:
// This job requires a pre-configured HTTP Client from the http package
// as externalData
// The first Type Parameter is the Type of returned Data & 2nd one is the Type
// of externalData
final anotherJob = QueryJob<String, Client>(
queryKey: "another-unique-key",
task: (queryKey, httpClient){
return httpClient
.get("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.body);
}
);
Now let’s use this job inside a Widget
Widget build(BuildContext context) {
// getting the instance of Client provided by the [provider] package
final client = Provider.of<Client>(context);
return QueryBuilder<String, void>(
job: anotherJob,
// passing the client as externalData
externalData: client,
builder: (context, query) {
if (!query.hasData || query.isLoading) {
return const CircularProgressIndicator();
}
// remember to always show a fallback widget/screen for errors too.
// It keeps the user aware of status of the application their using
// & saves their time
else if(query.hasError && query.isError){
return Text(
"My disappointment is immeasurable & my day is ruined for this stupid error: $error",
);
}
return Row(
children: [
// accessing the returned data & showing it
Text(query.data["title"]),
ElevatedButton(
child: const Text("Refetch"),
onPressed: () async {
await query.refetch();
},
),
],
);
},
);
}
That’s it. It is that easy to provide externalData
to a Query task
By default, when
externalData
changes the Query is not refetched. But you can change this behavior if you want the query to refetch everytimeexternalData
changes. Just setrefetchOnExternalDataChange
to true inQueryJob
for that specific Query or inQueryBowlScope
for all Queries
Query Refetch & Stale Time
Every query is updated when needed. But you can trigger a refetch for a Query or multiple queries manually. This can be useful after a mutation or the application data has changed for sure. The Query.refetch
allows to refetch a single query where QueryBowl.of(context).refetchQueries
allows refetching multiple queries at the same time
Here’s an example of single query refetch:
ElevatedButton(
onPressed: () async {
await query.refetch();
}
child: Text("Refetch")
);
Now for refetching multiple queries:
TextField(
controller: controller,
onSubmitted: (value){
QueryBowl.of(context).refetchQueries(
[exampleJob1.queryKey, exampleJob2.queryKey]
);
}
);
The
QueryBowl.of
method gives access to many methods used internally in Fl-Query. Its just an imperative way to do some things when it's necessary. It provides access to many useful methods & properties e.g.refetchQueries
,invalidateQueries
,resetQueries
,isFetching
etc
Stale Time means the amount of time when after a Query or multiple Query’s data should be considered as outdated
By default, Queries become stale as soon as fetch/refetch completes. But this can be configured using the staleTime
property of QueryBowlScope
for global configuration or per Query basis using the QueryJob
's property
final job = QueryJob<String, Client>(
queryKey: "another-unique-key",
// now the data of the query will become stale after 30 seconds when the
// fetch/refetch executes
staleTime: Duration(seconds: 30),
task: (queryKey, httpClient){
return httpClient
.get("https://jsonplaceholder.typicode.com/todos/1")
.then((response) => response.body);
}
);
Query status
A Query can be in following statuses
isSuccess
: When the task function returned data successfullyisError
: When the task function returned an errorisLoading
: When the task function is runningisRefetching
: When new data is being fetched or simply therefetch
method is executingisIdle
: When there's no data &Query
's task has not been yet run
These are Query Progress Status there’s another type of status of Query too. They’re called Data availability status. Following are:
hasData
: When query contains data no matters what's happening Basically,query.data != null
hasError
: When the query contains error. Basically,query.error != null
Remember, don’t use
isLoading
only for Loading Indicators as even if the QueryisSucessful
thedata
can still be null. So use!query.hasData
||query.isLoading
to ensure there’s no null exceptions
That is all about queries. It has so much more useful functionalities & features that it can’t be covered in a single article. Please visit the docs for more info (https://fl-query.vercel.app)
Mutations
Queries are used for retrieving Data or for GET requests where Mutation are, Unlike queries, typically used to create/update/delete data or perform server side effects
Basically, a mutation is a type of asynchronous operation that modifies already available data or adds data in a store or remote server
Just like QueryJob Mutation has MutationJob
that can be used to define the mutation operation or configuring different stuff
Here’s a MutationJob example:
final basicMutationJob = MutationJob<Map, Map<String, dynamic>>(
// instead of queryKey mutation has mutationKey
mutationKey: "unique-mutation-key",
task: (key, data) async {
final response = await http.post(
Uri.parse(
// to simulate a failing response environment
Random().nextBool()
? "https://jsonplaceholder.typicode.com/posts"
: "https://google.com",
),
headers: {'Content-type': 'application/json; charset=UTF-8'},
body: jsonEncode(data),
);
return jsonDecode(response.body);
},
);
The task
callback of MutationJob has a variable parameter. It might seem like externalData from QueryJob, but it's a little different. Instead of passing data through externalData argument of QueryBuider, you’ve to pass it through mutate
or mutateAsync
method of Mutation
Now let’s use this MutationJob
with our MutationBuilder
Widget build(context){
return MutationBuilder<Map, Map<String, dynamic>>(
job: basicMutationJob,
builder: (context, mutation) {
return Padding(
padding: const EdgeInsets.all(8.0),
// Its just basic Form
child: Column(
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: "Title"),
),
TextField(
controller: bodyController,
decoration: const InputDecoration(labelText: "Body"),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
final title = titleController.value.text;
final body = bodyController.value.text;
if (body.isEmpty || title.isEmpty) return;
// calling the mutate of the mutation
mutation.mutate({
"title": title,
"body": body,
"id": 42069, // the holy number as ID
}, onData: (data) {
// resetting the form
titleController.text = "";
bodyController.text = "";
});
},
child: const Text("Post"),
),
const SizedBox(height: 20),
// accessing the mutation result
if (mutation.hasData) Text("Response\n${mutation.data}"),
if (mutation.hasError) Text(mutation.error.toString()),
],
),
);
});
}
Above, there are 2 text fields that provide title and body of a Post & we’re running the mutation whenever the submit button is pressed. The mutate
method accepts 3 arguments
- 1st parameter/Variables
onData
: A callback that runs when result is available & Mutation is successfulonError
: A callback that runs when result is available & Mutation is successful
Optimistic Updates
The most interesting part of Mutation
s are the onMutate
callback of MutationBuilder
. It’s a callback that runs just before executing the MutationJob.task
. Here you can do all sort of crazy stuff. Such as adding predicted data to different queries before data being returned from the server or deleting an entire Query or anything you want. But in combination with onData
you can update your application’s data Optimistically, so the user doesn’t have to wait
Optimistic update means, updating Query data with predictable data before actually getting back any result. Then, when the real data arrives, replace the predicted data with the real data without letting the user even know
Here’s a simple example of Optimistic Updates:
MutationBuilder<Map, String>(
job: newUsernameMutation,
onMutate: (value) {
// getting the query that needs to be updated optimistically
QueryBowl.of(context)
.setQueryData<UserData, void>(job.queryKey, (oldData) {
oldData.name = value;
// you've to return a new instance of the oldData else fl-query
// will assume data hasn't been updated thus won't trigger any changes
return UserData.from(oldData);
});
},
onData: (data){
// replacing the predicted fake data with real data
QueryBowl.of(context)
.setQueryData<UserData, void>(job.queryKey, (oldData) {
oldData.name = data["name"];
// you've to return a new instance of the oldData else fl-query
// will assume data hasn't been updated thus won't trigger any changes
return UserData.from(oldData);
});
}
builder: (context, mutation){
....
....
....
}
);
This is all for Mutations. To learn more about Mutation read the docs
Hooks
Fl-Query supports both Vanilla Flutter & flutter_hooks. Both aren’t much different everything is same but in fl_query_hooks you additionally get 2 hooks useQuery
& useMutation
which you can use in place of QueryBuilder
& MutationBuilder
.
useQuery
It is basically QueryBuilder
without all the typical Builder boilerplate. So when I write the 1st example with hooks it’ll look like this:
class BasicHookExample extends HookWidget {
const BasicHookExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final query = useQuery(job: successJob, externalData: null);
if(!query.hasData || query.isLoading || query.isRefetching)
return const CircularProgressIndicator();
return Row(
children: [
Text(query.data!),
ElevatedButton(
child: const Text("Refetch"),
onPressed: () async {
await query.refetch();
},
), // Text
],
); // Row
}
}
useMutation
I guess, you know what useMutation
does. It’s a replacement for MutationBuilder
for flutter_hooks which makes code more clean & easier to read
Here’s a useMutation
example:
Widget build(context){
// the mutation object is the same passed as
// parameter in the builder method of MutationBuilder
final mutation = useMutation(job);
return /* .... (Imaginary Form) */;
}
This article only covers simple & most used features of Fl-Query. There are tons of more things that we can do with Fl-Query. Everything is available in the official docs which is a WIP. So you can contribute to that if you want. It’ll really help the project to move forward
Fl-Query is still under heavy development & it is expected to have bugs + unintended behavior. So if you find any please create an Issue with proper details. Also, we’re open for any suggestions. Suggest what you like or not or want Fl-Query to have. Probably contribute to the project with your own code & feature. It will be much appreciated
Since the project is in a very early stage it needs appropriate tests & I’m the worst excuse for Tester so Fl-Query needs some good testers who’re willing to contribute to the project with tests. If you want to contribute with tests. Please join the discussion by creating one
Give Fl-Query a ⭐Star⭐ in Github
Social
Follow me on: