MIT Pure Dart core Flutter

Big forms. Less code.One rebuild per keystroke.

Declare one schema; keyed_form generates the typed field refs, so each widget listens to its own slice of state — at 250 fields, across list reorders, with no string keys. Form logic stays plain Dart you can test without a widget tree.

$ flutter pub add keyed_form_flutter keyed_form_schema
// LIVE — type into a field
Emailrebuilds: 0
Passwordrebuilds: 0

// THE MODEL

Declare the shape once. Bind each field by name.

One schema file; the generator does the rest; your widgets stay plain Flutter. The demo runs this exact schema and controller — submit it empty.

@keyedSchema
library;

import 'package:keyed_form_schema/keyed_form_schema.dart';

part 'login_schema.kfg.dart';

final _loginSchema = ks.object({
  'email': ks
      .string(error: .text('Enter your email'))
      .email(error: .text('That does not look like an email')),
  'password': ks
      .string(error: .text('Enter your password'))
      .min(8, error: .text('At least 8 characters')),
});
import 'package:flutter/material.dart';
import 'package:keyed_form_flutter/keyed_form_flutter.dart';

import 'login_schema.dart';

part 'login_text_field.dart';

class LoginForm extends StatefulWidget {
  const LoginForm({super.key, this.onSignedIn});

  final void Function(LoginSchema value)? onSignedIn;

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final form = KeyedFormController<LoginSchema>(
    initialValue: LoginSchema.create(),
    mode: KeyedFormMode.onTouched,
    resolver: LoginSchema.validateData,
  );

  @override
  void dispose() {
    form.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return KeyedForm<LoginSchema>(
      controller: form,
      child: Column(
        children: [
          _LoginTextField(field: LoginFields.email, label: 'Email'),
          const SizedBox(height: 16),
          _LoginTextField(
            field: LoginFields.password,
            label: 'Password',
            obscureText: true,
          ),
          const SizedBox(height: 16),
          // handleSubmit needs a context below KeyedForm.
          Builder(
            builder: (context) => FilledButton(
              onPressed: () => form.handleSubmit(
                context,
                (value) => widget.onSignedIn?.call(value),
              ),
              child: const Text('Sign in'),
            ),
          ),
        ],
      ),
    );
  }
}
part of 'login_form.dart';

class _LoginTextField extends StatelessWidget {
  const _LoginTextField({
    required this.field,
    required this.label,
    this.obscureText = false,
  });

  final FieldRef<LoginSchema, String> field;
  final String label;
  final bool obscureText;

  @override
  Widget build(BuildContext context) {
    return KeyedFormField.text<LoginSchema>(
      field: field,
      builder: (context, f, controller) => TextField(
        controller: controller,
        obscureText: obscureText,
        onTapOutside: (_) => f.onBlur(),
        decoration: InputDecoration(
          labelText: label,
          errorText: f.errorText,
          border: const OutlineInputBorder(),
        ),
      ),
    );
  }
}
The example app's own files. Run the example ↗
// RUNNING — submit it empty
Email
Password
// WATCH — form.value.toMap()
{
  "email": "",
  "password": ""
}
// ERRORS — form.visibleErrorKeys
{}
Quickstart in 5 minutes →

// AGENT SKILL

Your coding agent already speaks keyed_form.

A ready-made Agent Skill teaches your coding agent the schema DSL, field refs, dynamic lists and submit flow — so "add a list of stops with a required city" comes out idiomatic on the first try. Works with any agent that supports Agent Skills.

Read SKILL.md →
mkdir -p .agents/skills/keyed_form && \
  curl -fsSL https://raw.githubusercontent.com/iamv4g/keyed_form/main/skills/keyed_form/SKILL.md \
  -o .agents/skills/keyed_form/SKILL.md

Using Claude Code? Link it:

mkdir -p .claude/skills && ln -s ../../.agents/skills/keyed_form .claude/skills/keyed_form

// DYNAMIC LISTS

Dynamic lists that never lose their place.

Rows are addressed by a stable id, never by index. Type in row 2, then drag it to the top — the text, the error and the focus go with it.

@keyedSchema
library;

import 'package:keyed_form_schema/keyed_form_schema.dart';

part 'packing_schema.kfg.dart';

final _packingSchema = ks.object({
  'items': ks
      .list(
        ks.object(className: 'PackingItemSchema', {
          'label': ks.string(error: .text('Give it a name')).min(1),
          'packed': ks.boolean().defaultTo(false),
        }),
      )
      .min(1, error: .text('Add at least one item')),
});
import 'package:dnd_kit_flutter/dnd_kit_flutter.dart';
import 'package:flutter/material.dart';
import 'package:keyed_form_flutter/keyed_form_flutter.dart';

import 'packing_schema.dart';

part 'packing_row.dart';

class PackingList extends StatefulWidget {
  const PackingList({super.key});

  @override
  State<PackingList> createState() => _PackingListState();
}

class _PackingListState extends State<PackingList> {
  final dnd = DndController();

  @override
  void dispose() {
    dnd.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return KeyedFieldList<PackingSchema, PackingItemSchema>(
      field: PackingFields.items,
      builder: (context, items, list) => Stack(
        children: [
          SortableScope(
            controller: dnd,
            strategy: SortableStrategies.dropOnOver,
            offsetResolver: SortableOffsets.verticalList,
            itemIds: [for (final item in items) DndId(item.clientId)],
            onMove: (d) => list.move(d.fromIndex, d.toIndex),
            child: Column(
              children: [
                for (final item in items)
                  SortableItem(
                    key: ValueKey(item.clientId),
                    id: DndId(item.clientId),
                    activationConstraint: const DndSensorActivationConstraint(
                      distance: 4,
                    ),
                    builder: (context, drag, child) => AnimatedContainer(
                      duration: const Duration(milliseconds: 150),
                      transform: Matrix4.translationValues(0, drag.offset.y, 0),
                      child: Opacity(
                        opacity: drag.isDragging ? 0 : 1,
                        child: child,
                      ),
                    ),
                    child: _PackingRow(
                      fields: PackingFields.item(itemClientId: item.clientId),
                      onRemove: () => list.removeById(item.clientId),
                    ),
                  ),
                TextButton.icon(
                  onPressed: () => list.append(PackingItemSchema.create()),
                  icon: const Icon(Icons.add),
                  label: const Text('Add item'),
                ),
              ],
            ),
          ),
          DndDragOverlay(
            controller: dnd,
            builder: (context, drag) => Material(
              elevation: 4,
              child: ListTile(
                leading: const Icon(Icons.drag_indicator),
                title: Text(list.byId(drag.activeId.value)?.label ?? ''),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
part of 'packing_list.dart';

class _PackingRow extends StatelessWidget {
  const _PackingRow({required this.fields, required this.onRemove});

  final ItemFieldRefs fields;
  final VoidCallback onRemove;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        const DndDragHandle(child: Icon(Icons.drag_indicator)),
        KeyedFormField<PackingSchema, bool>(
          field: fields.packed,
          anchor: false,
          builder: (context, f) => Checkbox(
            value: f.value ?? false,
            onChanged: (v) => f.onChanged(v ?? false),
          ),
        ),
        Expanded(
          child: KeyedFormField.text<PackingSchema>(
            field: fields.label,
            builder: (context, f, controller) => TextField(
              controller: controller,
              onTapOutside: (_) => f.onBlur(),
              decoration: InputDecoration(
                labelText: 'Item',
                errorText: f.errorText,
              ),
            ),
          ),
        ),
        IconButton(onPressed: onRemove, icon: const Icon(Icons.close)),
      ],
    );
  }
}
The example app's own files. Run the example ↗
// RUNNING — drag ⠿, then type
Press space on the handle to lift, arrow keys to move, space to drop.
Press space on the handle to lift, arrow keys to move, space to drop.
Press space on the handle to lift, arrow keys to move, space to drop.
// ROWS — index · clientId · label
0  1fa0c264…  Passport
1  5739b7ae…  Charger
2  f6531b43…  Sunscreen

Drag & drop by dnd_kit (Flutter · Jaspr) ↗

Lists, virtualization & scroll-to-error →

// MEASURED

O(1) isn't a claim here — it's measured.

One keystroke into one field, counted in the benchmark/ harness in the repository — reproducible, JIT debug numbers.

44 10 fields 44 50 fields 44 100 fields 44 250 fields

One keystroke rebuilds one field's subtree — 44 widgets, whether the form has 10 fields or 250.

~2 µs
To commit one field edit in a form with 20 fields and 100 rows. Measured in JIT debug mode; a release build is faster still.
Benchmarks & methodology →

// FEATURES

Everything else a real form needs.

Async validation

Server checks with a timeout. A check that throws or times out marks the field failed, not invalid, so retrying is the obvious next step.

validateAsyncisFailedValidation

Cross-field rules

Rules that read the whole draft, switch on and off with a condition, and pin their error to one field.

.refine(when:, path:)

Derived fields

One field writes another. The text binding tells a derived write from the user's own typing, so the caret never jumps.

addRelation

Nested objects & unions

Objects, lists of objects and discriminated unions, with typed refs all the way down to the leaf.

ks.objectks.listks.discriminatedUnion

Freeze a subtree

One call freezes a field, a row or a whole section against writes. Frozen fields still validate.

markReadOnly

Scroll to first error

Works in lazy lists too: it jumps to the right section first, then reveals the exact field.

revealFirst

Submit flow

Validate everything, reveal errors, scroll to the first one or run your callback, and track submitting — in one call.

handleSubmit

Server errors

Put a 422's messages on the right fields, by key or by wire path — shown at once, touched or not.

setServerErrors

Test without widgets

The controller is plain Dart. Drive a whole form in a unit test, with no widget tree to pump.

KeyedFormController

Your next form is one schema away.

One schema, one controller, and a form that knows exactly which field changed.

$ flutter pub add keyed_form_flutter keyed_form_schema
Build your first form → GitHub ↗