Skip to content

Form Validation + Async Save

This walkthrough shows .set(...), computed, effect, and useAsyncData working together for a real form.

Model & validation

dart
final name = signal(context, '');
final email = signal(context, '');
final dirty = signal(context, false);

final isValid = computed<bool>(context, (_) {
  final n = name().trim();
  final e = email().trim();
  return n.length >= 2 && e.contains('@');
});

final canSubmit = computed<bool>(context, (_) {
  return dirty() && isValid();
});

Async save

dart
final submit = useAsyncData<String>(context, () async {
  await Future.delayed(const Duration(milliseconds: 600));
  return 'Saved ${name()} <${email()}>';
});

Track saves with an effect:

dart
final lastSaved = signal(context, 'never');

effect(context, () {
  if (submit.status == AsyncStatus.success) {
    lastSaved.set('just now');
  }
});

UI wiring

dart
TextField(
  onChanged: (value) {
    name.set(value);
    dirty.set(true);
  },
  decoration: const InputDecoration(labelText: 'Name'),
);

TextField(
  onChanged: (value) {
    email.set(value);
    dirty.set(true);
  },
  decoration: const InputDecoration(labelText: 'Email'),
);

SignalBuilder(
  builder: (context) => ElevatedButton(
    onPressed: canSubmit() ? () => submit.refresh() : null,
    child: const Text('Save'),
  ),
);

SignalBuilder(
  builder: (context) => switch (submit.state) {
    AsyncDataSuccess(value: final msg) => Text(msg),
    AsyncDataError(error: final err) => Text('Error: $err'),
    AsyncDataLoading() => const Text('Saving...'),
    _ => const SizedBox.shrink(),
  },
);

This pattern keeps validation in computed, writes through .set(...), triggers async work via useAsyncData, and updates UI with SignalBuilder.

Flutter Example (from the example app)

dart
class FormWorkflowSection extends StatelessWidget {
  const FormWorkflowSection({super.key});

  @override
  Widget build(BuildContext context) {
    final name = signal<String>(context, '');
    final email = signal<String>(context, '');
    final dirty = signal<bool>(context, false);
    final lastSaved = signal<String>(context, 'never');

    final isValid = computed<bool>(context, (_) {
      final n = name().trim();
      final e = email().trim();
      return n.length >= 2 && e.contains('@');
    });

    final canSubmit = computed<bool>(context, (_) {
      return dirty() && isValid();
    });

    final submit = useAsyncData<String>(context, () async {
      await Future<void>.delayed(const Duration(milliseconds: 600));
      return 'Saved ${name()} <${email()}>';
    });

    effect(context, () {
      if (submit.status == AsyncStatus.success) {
        lastSaved.set('just now');
      }
    });

    final nameController = useMemoized(context, () => TextEditingController());
    final emailController = useMemoized(context, () => TextEditingController());
    effect(context, () {
      onEffectDispose(() {
        nameController.dispose();
        emailController.dispose();
      });
    });

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          key: const Key('form-name'),
          controller: nameController,
          decoration: const InputDecoration(labelText: 'Name'),
          onChanged: (value) {
            name.set(value);
            dirty.set(true);
          },
        ),
        const SizedBox(height: 8),
        TextField(
          key: const Key('form-email'),
          controller: emailController,
          decoration: const InputDecoration(labelText: 'Email'),
          onChanged: (value) {
            email.set(value);
            dirty.set(true);
          },
        ),
        const SizedBox(height: 8),
        SignalBuilder(
          builder: (context) {
            final valid = isValid();
            final can = canSubmit();
            final status = switch (submit.status) {
              AsyncStatus.idle => 'Idle',
              AsyncStatus.pending => 'Saving...',
              AsyncStatus.success => submit.data ?? 'Saved',
              AsyncStatus.error => 'Error: ${submit.error?.error ?? 'unknown'}',
            };
            return Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('valid: ${valid ? 'yes' : 'no'}'),
                Text('can submit: ${can ? 'yes' : 'no'}'),
                Text('status: $status'),
                Text('last saved: ${lastSaved()}'),
                const SizedBox(height: 8),
                ElevatedButton(
                  onPressed: can ? () async => submit.refresh() : null,
                  child: const Text('Save'),
                ),
              ],
            );
          },
        ),
      ],
    );
  }
}