Repositories

Flutter Data is organized around the concept of models which are data classes extending DataModel.

@DataRepository([TaskAdapter])
class Task extends DataModel<Task> {
  @override
  final int? id;
  final String title;
  final bool completed;

  Task({this.id, required this.title, this.completed = false});

  // ...
}

When annotated with @DataRepository (and adapters as arguments, as we’ll see later) a model gets its own fully-fledged repository.

Repository is the API used to interact with models, whether local or remote.

Assuming a Task model and its corresponding Repository<Task>, let’s see how to retrieve such resources from an API.

Finders

findAll

Using ref.tasks (short for ref.watch(tasksRepositoryProvider)) to obtain a repository we can find all resources in the collection.

Repository<Task> repository = ref.tasks;
final tasks = await repository.findAll();

// GET http://base.url/tasks

This async call triggered a request to GET http://base.url/tasks.

Understanding the magic ✨

How exactly does Flutter Data resolve the http://base.url/tasks URL?

Flutter Data adapters define functions and getters such as urlForFindAll, baseUrl and type among many others.

In this case, findAll will look up information in baseUrl and urlForFindAll (which defaults to type, and type defaults to tasks).

Result? http://base.url/tasks.

And, how exactly does Flutter Data instantiate Task models?

Flutter Data ships with a built-in serializer/deserializer for classic JSON. It means that the default serialized form of a Task instance looks like:

{
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

Of course, this too can be overridden like the JSON API Adapter does.

Method signature:

Future<List<T>?> findAll({
  bool? remote,
  bool? background,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  bool? syncLocal,
  OnSuccessAll<T>? onSuccess,
  OnErrorAll<T>? onError,
  DataRequestLabel? label,
});

Further information on the remote, background, params, headers, onSuccess, onError and label arguments available in common arguments below.

The syncLocal argument instructs local storage to synchronize the exact resources returned from the remote source (for example, to reflect server-side deletions).

final tasks = await ref.tasks.findAll(syncLocal: true);

Consider this example:

If a first call to findAll returns data for task IDs 1, 2, 3 and a second call updated data for 2, 3, 4 you will end up in your local storage with: 1, 2 (updated), 3 (updated) and 4.

Passing syncLocal: true to the second call will leave the local storage state with 2, 3 and 4.

📚 See API docs

findOne

Finds a resource by ID and saves it in local storage.

final task = await ref.tasks.findOne(1);

// GET http://base.url/tasks/1

Similar to what’s shown above in findAll, Flutter Data resolves the URL by using the urlForFindOne function. We can override this in an adapter.

For example, use path /tasks/something/1:

mixin TaskURLAdapter on RemoteAdapter<Task> {
  @override
  String urlForFindOne(id, params) => '$type/something/$id';
}

// would result in GET http://base.url/tasks/something/1

It can also take a T with an ID:

final task = await ref.tasks.findOne(anotherTaskWithId3);

// GET http://base.url/tasks/3

Method signature:

Future<T?> findOne(
  Object id, {
  bool? remote,
  bool? background,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  OnSuccessOne<T>? onSuccess,
  OnErrorOne<T>? onError,
  DataRequestLabel? label,
});

The remote, background, params, headers, onSuccess, onError and label arguments are detailed in common arguments below.

📚 See API docs

Save and delete

save

Persists a model to local storage and remote.

final savedTask = await repository.save(task);

Want to use the PUT verb instead of PATCH? Use this adapter:

mixin TaskURLAdapter on RemoteAdapter<Task> {
  @override
  String methodForSave(id, params) => id != null ? DataRequestMethod.PUT : DataRequestMethod.POST;
}

Method signature:

Future<T> save(
  T model, {
  bool? remote,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  OnSuccessOne<T>? onSuccess,
  OnErrorOne<T>? onError,
  DataRequestLabel? label,
});

The remote, params, headers, onSuccess, onError and label arguments are detailed in common arguments below.

📚 See API docs

delete

Deletes a model from local storage and sends a DELETE HTTP request.

await repository.delete(model);

Method signature:

Future<T?> delete(
  Object model, {
  bool? remote,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  OnSuccessOne<T>? onSuccess,
  OnErrorOne<T>? onError,
  DataRequestLabel? label,
});

The remote, params, headers, onSuccess, onError and label arguments are detailed in common arguments below.

📚 See API docs

Watchers

DataState is a class that holds state related to resource fetching and is practical in UI applications. It is returned in watchAll and watchOne.

class DataState<T> with EquatableMixin {
  T model;
  bool isLoading;
  DataException? exception;
  StackTrace? stackTrace;
  // ...
}

It’s typically used in a widget’s build method like:

class MyApp extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.tasks.watchAll();
    if (state.isLoading) {
      return CircularProgressIndicator();
    }
    if (state.hasException) {
      return ErrorScreen(state.exception, state.stackTrace);
    }
    return ListView(
      children: [
        for (final task in state.model)
          Text(task.title),
    // ...
  }
}

Why not used a Freezed union instead?

Because without forcing to branch, DataState easily allows rebuilding widgets when multiple substates happen simultaneously – a very common pattern. The tradeoff is having to remember to check for the loading and error substates.

watchAll

Watches all models of a given type in local storage (through ref.watch and watchAllNotifier).

For updates to any model of type Task to prompt a rebuild we can use:

class TasksScreen extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.tasks.watchAll();
    if (state.isLoading) {
      return CircularProgressIndicator();
    }
    // use state.model which is a List<Task>
  }
);

By default when first rendered it triggers a background findAll call with remote, params, headers, syncLocal and label arguments. See common arguments.

Method signature:

DataState<List<T>?> watchAll({
  bool? remote,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  bool? syncLocal,
  String? finder,
  DataRequestLabel? label,
});

But this can easily be overridden. Any method in the adapter with the exact findAll method signature and annotated with @DataFinder() will be available to supply to the finder argument as a string (method name).

Pass remote: false to prevent any remote fetch at all.

Note: Both watchAllProvider and watchAllNotifier are also available.

📚 See API docs

watchOne

Watches a model of a given type in local storage (through ref.watch and watchOneNotifier).

For updates to a given model of type Task to prompt a rebuild we can use:

class TaskScreen extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.tasks.watchOne(1);
    if (state.isLoading) {
      return CircularProgressIndicator();
    }
    // use state.model which is a Task
  }
);

By default when first rendered it triggers a background findOne call with model, remote, params, headers and label arguments. See common arguments.

Method signature:

DataState<T?> watchOne(
  Object model, {
  bool? remote,
  Map<String, dynamic>? params,
  Map<String, String>? headers,
  AlsoWatch<T>? alsoWatch,
  String? finder,
  DataRequestLabel? label,
});

But this can easily be overridden. Any method in the adapter with the exact findOne method signature and annotated with @DataFinder() will be available to supply to the finder argument as a string (method name).

Pass remote: false to prevent any remote fetch at all.

In addition, this watcher can react to relationships via alsoWatch:

watchOneNotifier(3, alsoWatch: (task) => [task.user]);

This feature is extremely powerful, actually any number of relationships can be watched:

watchOneNotifier(3, alsoWatch: (task) => [task.reminders, task.user, task.user.profile, task.user.profile.comments]);
Note: Both watchOneProvider and watchOneNotifier are also available.

watch

This method takes a DataModel and watches its local changes.

class TaskScreen extends HookConsumerWidget {
  final Task model;
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final task = ref.tasks.watch(model);
    return Text(task.title);
  }
);

Note that it returns a model, not a DataState.

notifierFor

Obtain the notifier for a model. Does not trigger a remote request.

final notifier = ref.tasks.notifierFor(task);

// equivalent to
ref.tasks.watchOneNotifier(task, remote: false);

By default, changes will be notified immediately and trigger widget rebuilds.

For performance improvements, they can be throttled by overriding graphNotifierThrottleDurationProvider.

Common arguments

remote

Request only models in local storage:

final tasks = await ref.tasks.findAll(remote: false);

Argument is of type bool and the default is true.

In addition to adapters, the @DataRepository annotation can take a remote boolean argument which will make it the default for the repository.

@DataRepository([TaskAdapter], remote: false)
class Task extends DataModel<Task> {
  // by default no operation hits the remote endpoint
}

background

Default false. Calling a finder with background = true will make it return immediately with the current value in local storage while triggering a remote request in the background. This is typically useful when using this primitive in certain adapter customizations.

params

Include query parameters (of type Map<String, dynamic>, in this case used for pagination and resource inclusion):

final tasks = await ref.tasks.findAll(
  params: {'include': 'comments', 'page': { 'limit': 20 }}
);

// GET http://base.url/tasks?include=comments&page[limit]=20

headers

Include HTTP headers as a Map<String, String>:

final tasks = await ref.tasks.findAll(
  headers: { 'Authorization': 't0k3n' }
);

onSuccess

Overrides the handler for the success state, useful when requiring a specific transformation of raw response data.

await ref.tasks.save(
  task,
  onSuccess: (data, label, adapter) async {
    final model = await adapter.onSuccess(data, label);
    return model as Task;
  },
);

RemoteAdapter#onSuccess is the default (overridable, of course). It esentially boils down to calls to deserialize.

onError

Overrides the error handler:

await ref.tasks.save(
  task,
  onError: (error, label, adapter) async {
    throw WrappedException(error);
  },
);

RemoteAdapter#onError is the default (overridable, of course). It essentially rethrows the error except if it is due to loss of connectivity or the remote resource was not found (HTTP 404).

label

Optional argument of type DataRequestLabel. See below.

Logging and labels

Labels are used in Flutter Data to easily track requests in logs. They carry an auto-generated requestId along with type and ID. They are provided by default and also by default finders log different events.

Some examples:

  • findAll/tasks@b5d14c
  • findOne/users#3@c4a1bb
  • findAll/tasks@b5d14c<c4a1bb

Request IDs can be nested like the last one above, where the b5d14c call happened inside c4a1bb. In other words, during the request for User with ID 3, a collection of tasks was also requested (presumably for that same user).

In the console these would be logged as:

flutter: 05:961   [findAll/tasks@b5d14c] requesting
flutter: 05:973   [findOne/users#3@c4a1bb] requesting
flutter: 05:974     [findAll/tasks@b5d14c<c4a1bb] requesting

with the nested labels properly indented.

Watchers also output useful logs in level 1 and 2.

Log levels can be set via the logLevel argument in log, and to adjust the global level:

ref.tasks.logLevel = 2;

In order to log custom information use something like:

final label = DataRequestLabel('save', type: 'users', id: '3');
ref.tasks.log(label, 'testing labels');

// or

final nestedLabel1 = DataRequestLabel('findAll',
  type: 'other', requestId: 'ff01b1', withParent: label);
ref.tasks.log(label, 'testing nested labels', logLevel: 2);

Local adapters

See Local Adapters.

Graph

Flutter Data uses a reactive bidirectional graph data structure to keep track of relationships and key-ID mappings. Changes on this graph will trigger updates to the watchers, by default once per change but a throttle can be configured to prevent superfluous re-renders.

graphNotifierThrottleDurationProvider
  .overrideWithValue(Duration(milliseconds: 100)),

Architecture overview

This is the dependency graph for an app with models User and Task:

Clients should only interact with repositories and adapters, while using the Adapter API to customize behavior.