Form Validation
Reactive forms with computed validation state.
ViewModel
dart
class SignUpViewModel extends ViewModel {
late final email = state('');
late final password = state('');
late final confirmPassword = state('');
late final signUpState = asyncState<void>();
// Derived validation — each recomputes automatically when the fields it
// reads change, so a `Watch` reading it rebuilds reactively.
late final emailError = computed(() {
if (email.value.isEmpty) return null;
if (!email.value.contains('@')) return 'Invalid email';
return null;
});
late final passwordError = computed(() {
if (password.value.isEmpty) return null;
if (password.value.length < 8) return 'At least 8 characters';
return null;
});
// Depends on both fields — updates when either password changes.
late final confirmError = computed(() {
if (confirmPassword.value.isEmpty) return null;
if (confirmPassword.value != password.value) return 'Passwords don\'t match';
return null;
});
// Computed-of-computed.
late final isValid = computed(() =>
email.value.isNotEmpty &&
password.value.isNotEmpty &&
emailError.value == null &&
passwordError.value == null &&
confirmError.value == null);
void signUp() {
if (!isValid.value) return;
load(signUpState, () => _authRepo.signUp(email.value, password.value));
}
}Widget
dart
class SignUpPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final vm = context.vm<SignUpViewModel>();
return Scaffold(
appBar: AppBar(title: Text('Sign Up')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
// Email field — rebuilds when its derived error changes
Watch((context) => TextField(
onChanged: (v) => vm.email.value = v,
decoration: InputDecoration(
labelText: 'Email',
errorText: vm.emailError.value,
),
)),
SizedBox(height: 16),
// Password field
Watch((context) => TextField(
onChanged: (v) => vm.password.value = v,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
errorText: vm.passwordError.value,
),
)),
SizedBox(height: 16),
// Confirm password — reacts to changes in either password field
Watch((context) => TextField(
onChanged: (v) => vm.confirmPassword.value = v,
obscureText: true,
decoration: InputDecoration(
labelText: 'Confirm Password',
errorText: vm.confirmError.value,
),
)),
SizedBox(height: 24),
// Submit button — re-enables as isValid changes, spinner while saving
Watch((context) {
if (vm.signUpState.value.isLoading) {
return const CircularProgressIndicator();
}
return ElevatedButton(
onPressed: vm.isValid.value ? vm.signUp : null,
child: const Text('Sign Up'),
);
}),
],
),
),
);
}
}What's happening
- Derived validation — each error is a
computed()that recomputes when the fields it reads change - Cross-field validation —
confirmErrorreacts to both password fields, not just the one being edited - Reactive submit —
Watchre-enables the button asisValidflips - No controller boilerplate — state lives in ViewModel
