juice_forms

Form state — fields, sync/async validation, and per-field selective rebuilds — as a Juice bloc.

pub package License: MIT

Why

A form is a pile of fields that each change independently. Rebuilding the whole form on every keystroke is wasteful and janky. juice_forms gives each field its own rebuild group, so a widget only re-renders when its field changes — no setState, no controllers, no notifiers.

Install

dependencies:
  juice_forms: ^0.3.0

Selective refresh — the core idea

Each field owns a rebuild group: FormsGroups.field('email'). Bind a widget to just that group and it rebuilds only when email changes:

class EmailInput extends StatelessJuiceWidget<FormsBloc> {
  EmailInput({super.key}) : super(groups: {FormsGroups.field('email')});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    final field = bloc.state.fields['email'];
    return TextField(
      onChanged: (v) => bloc.change('email', v),
      onTapOutside: (_) => bloc.touch('email'),
      decoration: InputDecoration(
        labelText: 'Email',
        errorText: field?.touched == true ? field?.error : null,
      ),
    );
  }
}

Typing in the password field never rebuilds this one. Whole-form widgets bind FormsGroups.any instead; the submit button binds FormsGroups.valid.

Create a form

final form = FormsBloc.withConfig(FormsConfig(
  fields: [
    FieldConfig(
      name: 'username',
      validators: [Validators.required(), Validators.minLength(3)],
      asyncValidator: (value, values) async {
        final taken = await api.isUsernameTaken(value);
        return taken ? 'Already taken' : null;
      },
    ),
    FieldConfig(name: 'email', validators: [Validators.required(), Validators.email()]),
    FieldConfig(name: 'password', validators: [Validators.required(), Validators.minLength(8)]),
  ],
  onSubmit: (values) => api.signUp(values),
));

form.change('email', 'a@b.com');
form.submit();

Validation

  • SyncString? Function(value, values), run in order, first error wins. values is every field's value, so cross-field rules work: Validators.matches('password').
  • Async — runs after sync passes, debounced (asyncDebounce), with stale results dropped: if the field changes again before the check returns, the old answer is discarded (latest value wins). field.validating is true while in flight.
  • submit() and validate() do their own full awaited pass, so submit is never fooled by an in-flight debounced check.

Built-in Validators: required, minLength, maxLength, email, matches, all.

Awaitable validate/submit (0.2)

validate()/submit() are fire-and-forget events. For "validate, then act" flows, the awaitable forms complete with the outcome — no settle-delays:

if (!await form.validateNow()) return;   // full sync+async pass → isValid
saveDraft(form.state.values);

final ok = await form.submitNow();       // true only if the handler succeeded

State

FormsState is data-only — validators live on the bloc's config, not in state.

Field / getter Meaning
fields Map<String, FieldState>
isValid no field reports an error
isDirty any field differs from its initial value
isValidating any async check in flight
values name → value snapshot
submitting / submitted / submitError submit lifecycle

Each FieldState: value, error, touched, validating, enabled, derived valid/dirty. Read a value with bloc.value<String>('email').

A never-validated field reports valid (its error is null). Call validate()/submit() for an authoritative full pass.

Grouped sections

Rebuild matching is set intersection, so a section widget that binds several field groups rebuilds when any of its members change — and stays inert for everything else. No extra API:

// Rebuilds on street/city/zip — not on email or password.
class AddressSection extends StatelessJuiceWidget<FormsBloc> {
  AddressSection({super.key})
      : super(groups: {
          FormsGroups.field('street'),
          FormsGroups.field('city'),
          FormsGroups.field('zip'),
        });

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    final f = bloc.state.fields;
    final sectionValid =
        ['street', 'city', 'zip'].every((k) => f[k]?.valid ?? true);
    // ... render the three inputs + a section-level indicator
  }
}

Three tiers fall out of one model: one field ({field('email')}), a section (several field groups), the whole form ({FormsGroups.any}).

Under consideration (post-0.1): first-class named groups — a single group rebuild key, group-level isValid/isDirty/reset, and an optional nested submit shape (values as {address: {...}}). The flat model + section composition above is the supported approach today.

Dynamic fields

form.register(FieldConfig(name: 'coupon'));
form.unregister('coupon');

What it owns / doesn't

Owns: field values, validation results, submit status — with selective rebuild groups. Does NOT own: rendering, input widgets, or layout. You bind your own TextField/Switch (the example shows it). There is no vendor seam — forms touch no platform SDK; the injected pieces are your validators and submit handler.

License

MIT License — see LICENSE.

Libraries

juice_forms
Form state — fields, sync/async validation, and per-field selective rebuilds — as a Juice bloc.