Forms and server validation
An edit form for a product. The app checks what it can (a name is required, a price is a number); the server checks the rest (the name is already taken) and answers 422 with an error per field. While the save is on its way the fields and the button are disabled; a refusal puts the server's message under the field it concerns, and typing into that field clears it; any other failure says so above the form; a success updates the cache and closes the screen. A mutation already holds every piece of state this needs — pending, the error, the saved product — so the form keeps none of its own.
The finished code
The mutation, with what a successful save does to the cache:
MutationOptions<Product, ProductDraft, void> saveProductMutation(
QueryClient client,
ProductApi api,
) =>
MutationOptions.simple(
mutationKey: QueryKey(const <Object?>['products', 'save']),
mutationFn: api.save,
onSuccess: (product, _, __) {
// The response is the product as saved: the detail has it now …
client.setQueryData<Product>(ProductKeys.detail(product.id), product);
// … and every list may have changed order or membership.
return client.invalidateQueries(
filters: QueryFilters(queryKey: ProductKeys.lists),
);
},
);
The form, reading the mutation through the QueryMixin methods:
class ProductFormScreen extends StatefulWidget {
const ProductFormScreen({super.key, this.initial});
/// The product being edited, or `null` for a new one.
final Product? initial;
State<ProductFormScreen> createState() => _ProductFormScreenState();
}
class _ProductFormScreenState extends State<ProductFormScreen> with QueryMixin {
final GlobalKey<FormState> _form = GlobalKey<FormState>();
late final TextEditingController _name =
TextEditingController(text: widget.initial?.name);
late final TextEditingController _price = TextEditingController(
text: widget.initial == null ? '' : '${widget.initial!.price / 100}',
);
void dispose() {
_name.dispose();
_price.dispose();
super.dispose();
}
Widget build(BuildContext context) {
final save = watchMutation(
saveProductMutation(queryClient, ProductApiScope.of(context)),
);
final result = save.value;
// The server's verdict, read off the mutation's state: no second copy
// of it to keep in sync.
final serverErrors = switch (result) {
MutationError(error: ValidationException(:final fieldErrors)) =>
fieldErrors,
_ => const <String, String>{},
};
void submit() {
if (!_form.currentState!.validate()) return;
save.mutate(
ProductDraft(
id: widget.initial?.id,
name: _name.text.trim(),
price: (double.parse(_price.text) * 100).round(),
),
callbacks: MutateCallbacks<Product, ProductDraft, void>(
// Runs only while this screen still listens, so the context is
// still in the tree.
onSuccess: (product, _, __) => Navigator.of(context).pop(product),
),
);
}
// Typing into a field the server refused clears the refusal.
void edited(String _) {
if (result.isError) save.reset();
}
return Scaffold(
appBar: AppBar(
title: Text(widget.initial == null ? 'New product' : 'Edit product'),
),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
if (result case MutationError(:final error)
when error is! ValidationException)
Text('Could not save: $error'),
TextFormField(
controller: _name,
enabled: !result.isPending,
onChanged: edited,
decoration: InputDecoration(
labelText: 'Name',
errorText: serverErrors['name'],
),
validator: (value) =>
(value ?? '').trim().isEmpty ? 'Required' : null,
),
TextFormField(
controller: _price,
enabled: !result.isPending,
onChanged: edited,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Price',
errorText: serverErrors['price'],
),
validator: (value) =>
double.tryParse(value ?? '') == null ? 'A number' : null,
),
const SizedBox(height: 16),
FilledButton(
onPressed: result.isPending ? null : submit,
child: result.isPending
? const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
),
),
);
}
}
The ValidationException comes from the API client in
Wiring dio or package:http: a 422 whose body has an
errors map becomes one, field by field.
How it works
- Two kinds of validation, two places. The
Form'svalidators check what the app can know, before anything is sent. The server's verdict comes back as the mutation's error and is shown through each field'serrorText. The two never compete: a field with a client-side problem is never sent. - The field errors are derived, not stored.
serverErrorsis a pattern match on the mutation's result: aMutationErrorwhose error is aValidationExceptionyields its map, anything else an empty one. There is nosetStatethat could miss a case. - Typing clears the refusal.
editedcallssave.reset()when the result is an error, which takes the mutation back to idle — and the derivedserverErrorswith it. - Pending disables the form.
result.isPendingturns off the fields and the button and puts a spinner in the button. A double tap cannot save twice. - The response updates the cache. The server answers with the product as
saved.
onSuccessin the options writes it to the detail entry, so the detail screen behind the form shows the new name without a request, and invalidates every list, whose order or membership may have changed. It returns the invalidation's future, so the mutation stays pending until the lists have refetched — the screen closes on current data. - Closing is a per-call callback.
onSuccessinMutateCallbacksruns after the options'onSuccess, and only while this screen still listens to the mutation. If the user has already left, it does not run, and there is noNavigatorcall on a context that is gone.
Awaiting instead of callbacks
When the code that saves is not the widget that reads the mutation — a button
elsewhere, or a view model — mutateAsync returns the saved product or
throws:
Future<void> saveAndReport(
MutationController<Product, ProductDraft, void> save,
ProductDraft draft,
ScaffoldMessengerState messenger,
) async {
try {
final product = await save.mutateAsync(draft);
messenger.showSnackBar(SnackBar(content: Text('Saved ${product.name}')));
} on ValidationException {
// The form shows these next to the fields; nothing to add here.
} on Object catch (error) {
messenger.showSnackBar(SnackBar(content: Text('Could not save: $error')));
}
}
mutate never throws, which is why the form uses it; mutateAsync does, so
every call needs the try.
Traps
- A mutation's error stays until something clears it. Without the
resetinedited, the server's "name is taken" would sit under the field while the user types a new name.resetis also how a form clears an error it showed when it is reopened with the same mutation. - Do not keep the server errors in state. Copying them into a field in
onErrormeans a second copy that has to be cleared on every retry, reset and reopening. Read them off the result. - Do not navigate from the options'
onSuccess. It runs even when the form is gone, and it has noBuildContext. The cache update belongs there; the navigation belongs in the call's callbacks. - The global error toast would fire too. With the
global error snackbar installed, its mutation
handler skips a
ValidationException— the form shows it — but toasts every other failure. The form's own "Could not save" line then duplicates it; keep one of the two. - Mutations are not retried by default. A save that failed on a timeout
fails at once. That is on purpose: repeating a write is rarely safe. Opt in
with
retry:on the options for an idempotentPUT.
Variations
- Optimistic save. For an edit that should appear before the server confirms it — a rename in place, a toggle — see Optimistic updates.
- A create form. The same screen with
initial: null: the draft has no id, the API client sends aPOST, and the list invalidation brings the new product into the lists. - Another call style. The same mutation reads through a
MutationControllerheld by a view model, or throughcontext.mutationor aMutationBuilderin a stateless widget.
useMutation gives the same state: isPending, error, reset. Form
libraries like React Hook Form hold the client-side validation; here that is
Flutter's own Form, and the server's field errors are matched off the
mutation's sealed result.
See also
- Mutations —
mutate,mutateAsync, the callbacks and the order they run in. - Updates from mutation responses — writing the response into the cache.
- Invalidations from mutations — what to invalidate after a write.