Skip to content

Commit bc36aa9

Browse files
authored
Enable refining the generic on a Mutation (#4253)
1 parent 5232968 commit bc36aa9

10 files changed

Lines changed: 282 additions & 88 deletions

File tree

packages/riverpod/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- Preserve persisted state if a provider throws.
99
- `provider.future` will now skip offline-persisted state by default.
1010
This avoids awkward unexpected provider rebuild when chaining persisted providers.
11+
- Made `Mutation.call` generic.
12+
This allows for better compatibility with generic-returning functions (thanks to @TekExplorer)
1113

1214
## 3.0.0-dev.17 - 2025-08-01
1315

packages/riverpod/lib/src/core/mutations.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,8 @@ sealed class Mutation<ResultT>
350350
/// // Use two different values as key
351351
/// ref.watch(mutation((todo.id, user.id)));
352352
/// ```
353-
Mutation<ResultT> call(Object? key);
353+
@optionalTypeArgs
354+
Mutation<ChangedT> call<ChangedT extends ResultT>(Object? key);
354355

355356
/// Starts a mutation and set its state based on the result of the callback.
356357
///
@@ -404,11 +405,11 @@ final class MutationImpl<ResultT>
404405

405406
@override
406407
final Object? label;
407-
final (Object? value, Mutation<ResultT> parent)? _key;
408+
final (Object? value, Mutation<Object?> parent)? _key;
408409

409410
@override
410-
MutationImpl<ResultT> call(Object? key) {
411-
return MutationImpl<ResultT>._keyed((key, this), label: label);
411+
MutationImpl<ChangedT> call<ChangedT extends ResultT>(Object? key) {
412+
return MutationImpl<ChangedT>._keyed((key, this), label: label);
412413
}
413414

414415
@override
@@ -515,9 +516,11 @@ final class MutationImpl<ResultT>
515516
);
516517
}
517518

519+
bool _matchesT(Mutation<Object?> other) => other is Mutation<ResultT>;
520+
518521
@override
519522
bool operator ==(Object other) {
520-
if (other is! MutationImpl<ResultT>) return false;
523+
if (other is! MutationImpl<ResultT> || !other._matchesT(this)) return false;
521524
if (_key != null) return _key == other._key;
522525

523526
return super == other;

packages/riverpod/test/feature/mutation_test.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,43 @@ void main() {
2323
expect(container.read(mut), isMutationSuccess<void>());
2424
});
2525

26+
test('Supports generic mutations', () async {
27+
final mut = Mutation<num>();
28+
final mutInt = mut<int>(null);
29+
final mutDouble = mut<double>(null);
30+
31+
final confusingMut = mut<num>(null);
32+
// none should be equal
33+
expect(mut, isNot(mutInt));
34+
expect(mut, isNot(mutDouble));
35+
expect(mutInt, isNot(mutDouble));
36+
37+
// check for type equality parity
38+
expect(confusingMut, isNot(mutInt));
39+
expect(mutInt, isNot(confusingMut));
40+
41+
// shows that using the same generic
42+
// with the same key will get the correct value
43+
expect(mutInt, equals(mut<int>(null)));
44+
45+
final container = ProviderContainer.test();
46+
47+
final sub = container.listen<MutationState<num>>(mut, (_, _) {});
48+
final subInt = container.listen<MutationState<int>>(mutInt, (_, _) {});
49+
final subDouble = container.listen<MutationState<double>>(
50+
mutDouble,
51+
(_, _) {},
52+
);
53+
54+
await mut.run(container, (tsx) async => 9);
55+
await mutInt.run(container, (tsx) async => 42);
56+
await mutDouble.run(container, (tsx) async => 3.14);
57+
58+
expect(sub.read(), isMutationSuccess<num>(9));
59+
expect(subInt.read(), isMutationSuccess<int>(42));
60+
expect(subDouble.read(), isMutationSuccess<double>(3.14));
61+
});
62+
2663
test('Concurrent run call ignores the previous run call', () async {
2764
final mut = Mutation<int>();
2865
final container = ProviderContainer.test();

website/docs/concepts2/mutations.mdx

Lines changed: 36 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,25 @@
22
title: Mutations (experimental)
33
---
44
import { Link } from "/src/components/Link";
5+
import CodeBlock from "@theme/CodeBlock";
6+
import { trimSnippet } from "/src/components/CodeSnippet";
7+
import listener from 'raw-loader!./mutations/listening.dart';
8+
import keyed from 'raw-loader!./mutations/keyed.dart';
9+
import generic from 'raw-loader!./mutations/generic.dart';
10+
import triggering from 'raw-loader!./mutations/triggering.dart';
11+
import switching from 'raw-loader!./mutations/switching.dart';
12+
import resetting from 'raw-loader!./mutations/resetting.dart';
513

614
:::caution
715
Mutations are experimental, and the API may change in a breaking way without
816
a major version bump.
917
:::
1018

1119
Mutations, in Riverpod, are objects which enable the user interface
12-
to react to state changes.
20+
to react to state changes.
1321
A common use-case is displaying a loading indicator while a form is being submitted
1422

15-
In short, mutations are to achieve effects such as this:
23+
In short, mutations are to achieve effects such as this:
1624
![Submit progress indicator](/img/concepts2/mutations/spinner.gif)!
1725

1826
Without mutations, you would have to store the progress of the form submission
@@ -42,50 +50,30 @@ a [Notifier].
4250

4351
Once we've defined a mutation, we can start using it inside <Link documentID="concepts2/consumers" /> or <Link documentID="concepts2/providers" />.
4452
For this, we will need a <Link documentID="concepts2/refs" /> and pick a listening method of our choice
45-
(typically [Ref.watch](https://pub.dev/documentation/hooks_riverpod/3.0.0-dev.17/hooks_riverpod/Ref/watch.html)).
46-
53+
(typically [Ref.watch]).
4754

4855
A typical example would be:
4956

50-
```dart
51-
class Example extends ConsumerWidget {
52-
const Example({super.key});
53-
54-
@override
55-
Widget build(BuildContext context) {
56-
// We listen to the current state of the "addTodo" mutation.
57-
// Listening to this will not perform any side effects by itself.
58-
/* highlight-next-line */
59-
final addTodoState = ref.watch(addTodo);
60-
61-
return Row(
62-
children: [
63-
ElevatedButton(
64-
style: ButtonStyle(
65-
// If there is an error, we show the button in red
66-
/* highlight-next-line */
67-
backgroundColor: switch (addTodoState) {
68-
MutationError() => WidgetStatePropertyAll(Colors.red),
69-
_ => null,
70-
},
71-
),
72-
onPressed: () {
73-
// TODO
74-
},
75-
child: const Text('Add todo'),
76-
),
77-
78-
// The operation is pending, let's show a progress indicator
79-
/* highlight-next-line */
80-
if (addTodoState is MutationPending) ...[
81-
const SizedBox(width: 8),
82-
const CircularProgressIndicator(),
83-
],
84-
],
85-
);
86-
}
87-
}
88-
```
57+
<CodeBlock>{trimSnippet(listener)}</CodeBlock>
58+
59+
### Scoping a mutation
60+
61+
Sometimes, you may want to have multiple instances of the same mutation.
62+
63+
This can include things like an id, or any other parameter that makes the mutation unique.
64+
65+
This is useful if you want to have multiple instances of the same mutation,
66+
such as deleting a specific item in a list
67+
68+
Simply call the mutation with the unique key:
69+
70+
<CodeBlock>{trimSnippet(keyed)}</CodeBlock>
71+
72+
Sometimes, these mutations have a generic return type,
73+
such as if an api response may have different response types
74+
based on the input parameters, such as with deserialization.
75+
76+
<CodeBlock>{trimSnippet(generic)}</CodeBlock>
8977

9078
### Triggering a mutation
9179

@@ -94,28 +82,7 @@ So far, we've listened to the state of a mutation, but nothing actually happens
9482
To trigger a mutation, we can use [Mutation.run], pass our mutation, and provide an asynchronous callback
9583
that updates whatever state we want. Lastly, we'll need to return a value matching the generic type of the mutation.
9684

97-
```dart
98-
ElevatedButton(
99-
onPressed: () {
100-
// Trigger the mutation, and run the callback.
101-
// During the callback, we obtain a MutationTransaction (tsx) object
102-
// which we can use to access providers and perform operations.
103-
addTodo.run(ref, (tsx) async {
104-
// We use tsx.get to access providers within mutations.
105-
// This will keep the provider alive for the duration of the operation.
106-
final todoNotifier = tsx.get(todoNotifierProvider);
107-
108-
// We perform a perform request using a Notifier.
109-
final createdTodo = await todoNotifier.addTodo('Eat a cookie');
110-
111-
// We return the created todo. This enables our UI to show information
112-
// about the created todo, such as its ID/creation date/etc.
113-
return createdTodo;
114-
});
115-
},
116-
child: const Text('Add todo'),
117-
);
118-
```
85+
<CodeBlock>{trimSnippet(triggering)}</CodeBlock>
11986

12087
### The different mutation states and their meaning
12188

@@ -127,14 +94,7 @@ Mutations can be in one of the following states:
12794

12895
You can switch over the different states using a `switch` statement:
12996

130-
```dart
131-
switch (addTodo.state) {
132-
case MutationPending():
133-
case MutationError():
134-
case MutationSuccess():
135-
case MutationIdle():
136-
}
137-
```
97+
<CodeBlock>{trimSnippet(switching)}</CodeBlock>
13898

13999
### After a mutation has been started once, how to reset it to its idle state?
140100

@@ -147,21 +107,14 @@ This is similar to how <Link documentID="concepts2/auto_dispose"/> works, but fo
147107
Alternatively, you can manually reset a mutation to its idle state
148108
by calling the [Mutation.reset] method:
149109

150-
```dart
151-
ElevatedButton(
152-
onPressed: () {
153-
// Reset the mutation to its idle state.
154-
addTodo.reset(ref);
155-
},
156-
child: const Text('Reset mutation'),
157-
);
158-
```
110+
<CodeBlock>{trimSnippet(resetting)}</CodeBlock>
159111

160112
[MutationPending]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/MutationPending-class.html
161113
[MutationError]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/MutationError-class.html
162114
[MutationSuccess]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/MutationSuccess-class.html
163115
[MutationIdle]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/MutationIdle-class.html
164116
[Mutation.reset]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/Mutation/reset.html
117+
[Mutation.run]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/Mutation/run.html
165118
[Mutation]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/experimental_mutation/Mutation-class.html
166119
[Notifier]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/riverpod/Notifier-class.html
167-
[Mutation.run]: https://pub.dev/documentation/flutter_riverpod/3.0.0-dev.17/experimental_mutation/Mutation/run.html
120+
[Ref.watch]: https://pub.dev/documentation/riverpod/3.0.0-dev.17/riverpod/Ref/watch.html
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import 'package:riverpod/experimental/mutation.dart';
2+
import 'package:riverpod/riverpod.dart';
3+
4+
class ApiResponse {}
5+
6+
class CreatedResponse<ValueT> implements ApiResponse {
7+
CreatedResponse(this.value);
8+
9+
factory CreatedResponse.fromJson(
10+
Map<String, Object?> json,
11+
ValueT Function(Map<String, Object?>) fromJson,
12+
) {
13+
return CreatedResponse(fromJson(json['data']! as Map<String, Object?>));
14+
}
15+
final ValueT value;
16+
}
17+
18+
class Todo {
19+
Todo({required this.id, required this.title});
20+
21+
factory Todo.fromJson(Map<String, Object?> json) {
22+
return Todo(id: json['id']! as int, title: json['title']! as String);
23+
}
24+
25+
final int id;
26+
final String title;
27+
}
28+
29+
final apiProvider = Provider((ref) {
30+
// dummy client.
31+
return (
32+
post: (String url, {Map<String, dynamic>? data}) {
33+
return (data: <String, dynamic>{});
34+
},
35+
);
36+
});
37+
38+
/* SNIPPET START */
39+
final create = Mutation<ApiResponse>();
40+
final createTodo = create<CreatedResponse<Todo>>('create_todo');
41+
42+
Future<void> executeCreateTodo(MutationTarget ref) async {
43+
await createTodo.run(ref, (tsx) async {
44+
final client = tsx.get(apiProvider);
45+
final response = client.post('/todos', data: {'title': 'Eat a cookie'});
46+
return CreatedResponse<Todo>.fromJson(response.data, Todo.fromJson);
47+
});
48+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import 'package:riverpod/experimental/mutation.dart';
2+
3+
const todo = (id: 0);
4+
5+
/* SNIPPET START */
6+
final removeTodo = Mutation<void>();
7+
final removeTodoWithId = removeTodo(todo.id);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:riverpod/experimental/mutation.dart';
4+
5+
final addTodo = Mutation<void>();
6+
7+
/* SNIPPET START */
8+
class Example extends ConsumerWidget {
9+
const Example({super.key});
10+
11+
@override
12+
Widget build(BuildContext context, WidgetRef ref) {
13+
// We listen to the current state of the "addTodo" mutation.
14+
// Listening to this will not perform any side effects by itself.
15+
/* highlight-next-line */
16+
final addTodoState = ref.watch(addTodo);
17+
18+
return Row(
19+
children: [
20+
ElevatedButton(
21+
style: ButtonStyle(
22+
// If there is an error, we show the button in red
23+
/* highlight-next-line */
24+
backgroundColor: switch (addTodoState) {
25+
MutationError() => const WidgetStatePropertyAll(Colors.red),
26+
_ => null,
27+
},
28+
),
29+
onPressed: () {
30+
addTodo.run(ref, (tsx) async {
31+
// todo
32+
});
33+
},
34+
child: const Text('Add todo'),
35+
),
36+
37+
// The operation is pending, let's show a progress indicator
38+
/* highlight-next-line */
39+
if (addTodoState is MutationPending) ...[
40+
const SizedBox(width: 8),
41+
const CircularProgressIndicator(),
42+
],
43+
],
44+
);
45+
}
46+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:riverpod/experimental/mutation.dart';
4+
5+
final addTodo = Mutation<Todo>();
6+
7+
class Todo {}
8+
9+
class ResetAddTodoButton extends ConsumerWidget {
10+
const ResetAddTodoButton({super.key});
11+
@override
12+
Widget build(BuildContext context, WidgetRef ref) {
13+
/* SNIPPET START */
14+
return ElevatedButton(
15+
onPressed: () {
16+
// Reset the mutation to its idle state.
17+
addTodo.reset(ref);
18+
},
19+
child: const Text('Reset mutation'),
20+
);
21+
/* SNIPPET END */
22+
}
23+
}

0 commit comments

Comments
 (0)