riverpod_crud

Overview

riverpod_crud is a lightweight Dart package that helps you build reusable CRUD state management with Riverpod.

It is designed for apps that repeatedly need the same loading, error, refresh, pagination, create, update, and delete behavior across models such as User, Product, Post, or Order.

This package follows Clean Architecture with Repository Pattern and Riverpod-based state management.

The package is pure Dart, depends on riverpod, and is compatible with Riverpod 3. It can be used in Flutter apps through Riverpod providers.

Features

  • Generic CRUD support with <T, ID>.
  • Repository-based API through CrudRepository<T, ID>.
  • Riverpod Notifier through CrudNotifier<T, ID>.
  • Immutable state through CrudState<T>.
  • Initial loading, refresh, and load-more flags.
  • Page-based pagination with PaginationConfig.
  • Create, update, and delete operations.
  • Optional optimistic updates with rollback on failure.
  • Readable error state and clearError().
  • reset() for returning to an empty initial state.
  • No code generation, Freezed, or build runner.

Architecture

UI Layer
-> Riverpod Provider / Notifier Layer
-> Repository Layer
-> Data Source Layer
-> API / Database

The package provides the provider, notifier, state, and repository abstraction. Your app owns the model classes and repository implementations.

Installation

Add the package to your pubspec.yaml:

dependencies:
  riverpod_crud: ^0.0.2

For Flutter UI code, use your normal Riverpod setup in the app.

Quick Start

Create a model:

class User {
  final int id;
  final String name;

  const User({
    required this.id,
    required this.name,
  });
}

Create a provider:

final usersProvider = crudNotifierProvider<User, int>(
  repository: UserRepository(),
  getId: (user) => user.id,
);

Use the state and notifier:

final state = ref.watch(usersProvider);
final notifier = ref.read(usersProvider.notifier);

notifier.fetchInitial();
notifier.refresh();
notifier.loadMore();
notifier.create(user);
notifier.update(user.id, user);
notifier.delete(user.id);

Creating a Repository

CrudRepository<T, ID> is the contract between the notifier and your data layer.

T is your model type, such as User. ID is the type of the model ID, such as int or String.

class UserRepository implements CrudRepository<User, int> {
  @override
  Future<List<User>> fetch({
    int page = 1,
    int limit = 20,
  }) async {
    return api.fetchUsers(page: page, limit: limit);
  }

  @override
  Future<User> create(User item) async {
    return api.createUser(item);
  }

  @override
  Future<User> update(int id, User item) async {
    return api.updateUser(id, item);
  }

  @override
  Future<void> delete(int id) async {
    await api.deleteUser(id);
  }
}

Your repository can use REST, GraphQL, Firebase, SQLite, Hive, or any other data source.

Creating a Provider

Use crudNotifierProvider to create a Riverpod provider for a model.

final usersProvider = crudNotifierProvider<User, int>(
  repository: UserRepository(),
  getId: (user) => user.id,
);

The getId function tells the notifier how to find an item's unique ID. It is used when updating or deleting items from local state.

For example:

getId: (user) => user.id

Using in Flutter UI

In Flutter, watch the provider state and read the notifier from your Riverpod UI setup. Call fetchInitial() when the screen starts.

@override
void initState() {
  super.initState();
  Future.microtask(
    () => ref.read(usersProvider.notifier).fetchInitial(),
  );
}

@override
Widget build(BuildContext context) {
  final state = ref.watch(usersProvider);
  final notifier = ref.read(usersProvider.notifier);

  if (state.isLoading) {
    return const Center(child: CircularProgressIndicator());
  }

  return Column(
    children: [
      if (state.error != null)
        Text(state.error!),
      ElevatedButton(
        onPressed: notifier.refresh,
        child: const Text('Refresh'),
      ),
      Expanded(
        child: ListView.builder(
          itemCount: state.items.length,
          itemBuilder: (context, index) {
            final user = state.items[index];

            return ListTile(
              title: Text(user.name),
              trailing: IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () => notifier.delete(user.id),
              ),
            );
          },
        ),
      ),
      ElevatedButton(
        onPressed: state.hasMore ? notifier.loadMore : null,
        child: state.isLoadingMore
            ? const Text('Loading...')
            : const Text('Load more'),
      ),
    ],
  );
}

Pagination

PaginationConfig controls page-based loading.

final usersProvider = crudNotifierProvider<User, int>(
  repository: UserRepository(),
  getId: (user) => user.id,
  paginationConfig: const PaginationConfig(
    initialPage: 1,
    limit: 10,
  ),
);

loadMore() fetches the next page only when state.hasMore is true.

The notifier sets hasMore to false when the repository returns fewer items than the configured limit.

Refresh

Use refresh() when the user pulls to refresh or taps a refresh button.

await ref.read(usersProvider.notifier).refresh();

Refresh loads the first page again, replaces the current list, resets the page number, and updates state.isRefreshing while the request is running.

Create, Update, and Delete

Create an item:

await ref.read(usersProvider.notifier).create(
  const User(id: 3, name: 'Linus'),
);

Update an item:

await ref.read(usersProvider.notifier).update(
  user.id,
  User(id: user.id, name: 'Updated name'),
);

Delete an item:

await ref.read(usersProvider.notifier).delete(user.id);

Created items are added to the top of the current list. Updated items are replaced by matching their ID with getId. Deleted items are removed by ID.

Optimistic Updates

Optimistic updates are enabled by default.

final usersProvider = crudNotifierProvider<User, int>(
  repository: UserRepository(),
  getId: (user) => user.id,
  optimisticUpdate: true,
);

When optimisticUpdate is true, create, update, and delete operations update local state before the repository call finishes. If the repository throws, the notifier rolls back to the previous state and stores the error message in state.error.

Disable optimistic updates when you want the UI to change only after the repository succeeds.

final usersProvider = crudNotifierProvider<User, int>(
  repository: UserRepository(),
  getId: (user) => user.id,
  optimisticUpdate: false,
);

Error Handling

All notifier methods catch repository errors and store a readable message in state.error.

if (state.error != null) {
  return Text(state.error!);
}

Clear the current error:

ref.read(usersProvider.notifier).clearError();

You can throw CrudException from your repository when you want full control over the message.

throw const CrudException('Unable to load users');

API Reference

CrudRepository<T, ID>

An abstraction for your data layer. Implement it to define how a model is fetched, created, updated, and deleted.

Methods:

  • fetch({int page = 1, int limit = 20})
  • create(T item)
  • update(ID id, T item)
  • delete(ID id)

CrudNotifier<T, ID>

A Riverpod Notifier that manages CRUD state. It calls your repository and updates CrudState<T>.

Methods:

  • fetchInitial()
  • refresh()
  • loadMore()
  • create(T item)
  • update(ID id, T item)
  • delete(ID id)
  • clearError()
  • reset()

CrudNotifierConfig<T, ID>

The configuration object used to create a CrudNotifier. It stores the repository, getId callback, PaginationConfig, and optimisticUpdate setting.

CrudState<T>

The immutable state object used by CrudNotifier.

Fields:

  • items: current loaded items.
  • isLoading: first page is loading.
  • isRefreshing: refresh is running.
  • isLoadingMore: next page is loading.
  • error: latest readable error message.
  • page: current page number.
  • hasMore: whether another page can be loaded.

PaginationConfig

Configures page-based loading.

const PaginationConfig(
  initialPage: 1,
  limit: 20,
)

crudNotifierProvider

A helper that creates a NotifierProvider<CrudNotifier<T, ID>, CrudState<T>>.

Example

The example/ folder contains a Flutter app with fake user CRUD:

  • Load users.
  • Refresh users.
  • Load more users.
  • Add a user.
  • Update a user.
  • Delete a user.
  • Display loading and error states.

Run it from the example folder with Flutter tooling.

Testing

Run the package tests:

dart test

The test suite covers:

  • Initial fetch.
  • Refresh.
  • Pagination.
  • Create.
  • Update.
  • Delete.
  • Repository error handling.
  • Optimistic delete rollback.

Roadmap

  • Cursor-based pagination.
  • Optional cache integration hooks.
  • Batch create, update, and delete helpers.
  • More examples for API and local database repositories.

Website

For updates, support, and related package information, visit csjotlab.com.

License

MIT License. See LICENSE for details.

Libraries

riverpod_crud