Auto-Saving with Signal Forms

Here at Trellis we often auto-save form fields as they are edited instead of waiting for the entire form to be submitted. The goal is to ensure that changes are automatically saved so work isn't lost if the user forgets to save or the tab is closed/refreshed. For example, filling in checkout details or editing the draft of an auction item.

There are two possibilities for auto-saving:

  1. Save each field or group individually as it changes and is valid/dirty.
  2. When the form value/state changes, take the whole form and extract only valid and dirty fields.

We use individual field saving with the idea that once a field has been made valid and moved on from then it is not typically changed again. This way we aren't saving the same values over and over again when an unrelated field changes.

Our GraphQL backend is built to allow partial updates to enable this functionality. The tricky part is handling this in the complicated Angular forms systems.

A look at the past, Auto-Saving with Reactive Forms

With Angular reactive or template forms we use an autoSave directive applied to a form control or group which injects NgControl and watches the valueChanges and statusChanges. The directive then creates an object with that value and passes it to an AutoSaveService<AutoSaveModel> service which must be provided in the injection context.

@Injectable()
class MyAutoSaveService implements AutoSaveService<AutoSaveModel> {
    update(update: DeepPartial<AutoSaveModel>): void {
        // Handle the update
    }
}

@Component({
    providers: [
        MyAutoSaveService,
        provideExistingAutoSaveService(MyAutoSaveService),
    ],
    template: ` <input
        type="text"
        formControlName="myField"
        autoSave
        autoSavePath="path.in.output"
        autoSaveTransform="transformMyField.bind(this)" />`,
})
class MyComponent {
    protected transformMyField(value: string): string {
        return value.trim() || null;
    }
}

This has worked well enough for us but does have some major problems:

  1. The autoSavePath is not type safe to the paths on the model.
  2. The autoSavePath value type is not type checked to match the form value type.
  3. Transforming the value requires passing a function into the directive which requires either defining it as an arrow function or binding it in the template. This is also mostly not type safe.
  4. Directives cannot be conditionally applied, so some shared form group components have to be duplicated into auto-saving and non-auto-saving versions.
  5. The AutoSaveModel in the service was made a deep partial for historical reasons and doesn't easily support cases where we need to auto-save a nested object as a non-partial object.
  6. The directive expects a service instance to exist in the injection context of the directive, which complicates storybooks and testing.

Enter Signal Forms

Now that Signal Forms are out of experimental with Angular 22, we were excited to start using them and investigate how they could help us solve some of the problems we had with the current auto-save implementation.

Signal Forms do work with our existing autoSave directive since [formField] creates a ngControl. But what if we reconsider from the ground up how Signal Forms change the auto-saving story and how they can help solve the problems discussed above?

Our requirements:

  1. Saves individual valid and changed fields regardless of the entire form validity.
  2. A code-first implementation like Signal Forms without needing a complicated directive.
  3. Completely type safe.
  4. A simple way to transform the value before saving.
  5. Emits to an output on the component rather than expecting a specific service to exist in the injection context.
  6. Signal Form arrays should be able to auto-save individual array items.

Simplified Explorations

We started with a simple implementation to explore options before expanding it to a complex system. The Angular Signal Form Guide has a simple example of automatic saving by using an effect and has a small aside about partial saves. Using this as a base, we can construct a simple auto-save effect for a single field.

Effects should be used sparingly, but this is a perfect use-case since we want to do something when a set of signals changes.

class MyComponent {
    readonly autoSave = output<AutoSaveModel>();

    protected readonly myFormData = signal<{ myField: string }>({
        myField: '',
    });

    protected readonly myForm = form(this.myFormData, (path) => {
        required(path.myField);
    });

    constructor() {
        effect(() => {
            if (
                !this.myForm.myField.disabled() &&
                !this.myForm.myField.hidden() &&
                this.myForm.myField.valid() &&
                this.myForm.myField.dirty() &&
                !this.myForm.myField.pending()
            ) {
                this.autoSave.emit({ myField: this.myForm.myField.value() });
            }
        });
    }
}

Fairly straight forward, but it becomes quite verbose when auto-saving every field in a large form. Let's abstract it like the good programmers we try to be!

function autoSave<TValue, TAutoSaveModel>(
    formField: FieldTree<TValue>,
    output: OutputRef<TAutoSaveModel>,
    create: (value: TValue) => TAutoSaveModel
): EffectRef {
    return effect(() => {
        if (
            !formField.disabled() &&
            !formField.hidden() &&
            formField.valid() &&
            formField.dirty() &&
            !formField.pending()
        ) {
            output.emit(create(formField.value()));
        }
    });
}

class MyComponent {
    readonly autoSave = output<AutoSaveModel>();

    // ...form creation

    constructor() {
        autoSave(this.myForm.myField, this.autoSave, (value) => ({
            myField: value,
        }));
    }
}

This is better, but we are still repeating work by manually constructing the potentially deep paths in the AutoSaveModel object. We could pass in a path array path: ["myField", "a", "b"] and construct the object automatically, but then we lose all our nice type safety!

I put some thought into this and realized how much I liked the Signal Forms schema builder pattern. What if we built something like that?

AutoSaveForm

Our end goal is something like this:

class MyComponent {
    readonly autoSave = outputFromObservable(
        autoSaveForm<AutoSaveModel, FormModel>(this.myForm, (from, to) => {
            autoSave(from.myField, to.myField, {
                transform: (value) => value.trim(),
            });
        })
    );
}

autoSaveForm constructs the per-field effects which push their constructed AutoSaveModel into the Observable returned by the function. This observable stream can then be consumed as needed, either directly or converting it to an output with outputFromObservable.

Pathing and how to go From here To there

Signal Forms use an excellent path builder system that dynamically creates properties from an interface using a Proxy. This is simple enough to implement for both the from and to paths. The implementations here are heavily inspired by the Angular Signal Forms SchemaPathNode implementation.

The AutoSaveFromPathNode keeps track of auto-save entries added to that field path with the output paths from the associated to node. The AutoSaveFromPathTree type is very similar to the Angular Signal Forms SchemaPathTree and is omitted here.

// Allows the proxy to access the internal class
const INTERNAL = Symbol('internal');

class AutoSaveFromPathNode {
    readonly autoSaveEntries: AutoSaveEntry[] = [];

    readonly children = new Map<string, AutoSaveFromPathNode>();

    readonly pathProxy: AutoSaveFromPathTree<unknown> = new Proxy(
        this,
        AUTO_SAVE_FIELD_PATH_PROXY_HANDLER
    ) as AutoSaveFromPathTree<unknown>;

    static unwrapProxy(path: AutoSaveFromPath<unknown>): AutoSaveFromPathNode {
        return (path as unknown as AutoSaveFromPathProxy)[INTERNAL];
    }

    addAutoSave(path: string[], config?: AutoSaveConfig): void {
        // Add to the autoSaveEntries array
    }

    getOrCreateChild(key: string): AutoSaveFromPathNode {
        // Get existing child or create a new one and return its pathProxy
    }
}

export const AUTO_SAVE_FIELD_PATH_PROXY_HANDLER: ProxyHandler<AutoSaveFromPathNode> =
    {
        get(node: AutoSaveFromPathNode, property: string | symbol) {
            if (property === INTERNAL) {
                return node;
            }

            return node.getOrCreateChild(property).pathProxy;
        },
    };

The AutoSaveToPathNode is similar but tracks the keys used to get to the node from the root. This path array will be used later to construct the output model.

class AutoSaveToPathNode {
    readonly children = new Map<string, AutoSaveToPathNode>();

    readonly path: string[];

    readonly pathProxy: AutoSaveToPathTree<unknown> = new Proxy(
        this,
        AUTO_SAVE_TO_PATH_PROXY_HANDLER
    ) as unknown as AutoSaveToPathTree<unknown>;

    constructor(
        readonly keys: string[],
        key: string | null
    ) {
        this.path = key ? [...this.keys, key] : this.keys;
    }

    static unwrapProxy(path: AutoSaveToPath<unknown>): AutoSaveToPathNode {
        return (path as unknown as AutoSaveToPathNode)[INTERNAL];
    }

    getOrCreateChild(key: string): AutoSaveToPathNode {
        // Get existing child or create a new one and return its pathProxy
    }
}

Type Safety

One of the tricky parts here is making sure that path selection is fully type safe. This is achieved with two pieces:

  1. Only allowing selecting to paths that can construct a valid instance of the model.
  2. Making a transform required if the from and to path types don't match.

1. Only valid models please!

In many forms some fields shouldn't be saved individually but should be saved as a group. We would like to represent this in our AutoSaveModel interface and ensure that a developer can't accidentally save a partial model.

For example:

interface AutoSaveModel {
    firstName?: string;
    lastName?: string;
    address?: {
        line1: string;
        line2?: string;
        locality: string;
        area: string;
    };
}

Some partials of this model are allowed. { firstName }, { lastName }, and { address } would be valid. However, { address: { line1 } } is not valid since the other address fields are required.

We achive this using some crafty conditional types to create the AutoSaveToPathTree. For each object-like value in the model, we pull out only keys that can be used to construct a valid version of that model. This ensures that TypeScript won't see to.address.line1 as a valid key on the AutoSaveToPathTree but will allow to.address. Remember that at runtime this is a Proxy, so we need to make this compile time safe.

// Picks the keys out of the model that can work on their own by checking if just that
// sub-model is assignable to the model.
type ValidAutoSaveKeys<Model> = {
    [Key in keyof Model as Pick<Model, Key> extends Model
        ? Key
        : never]-?: Model[Key];
};

export type AutoSaveToPathTree<
    Model,
    AutoSaveModel = ValidAutoSaveKeys<Model>,
> = [keyof AutoSaveModel] extends [never]
    ? AutoSaveToPath<Model>
    : AutoSaveToPathWithMaybeSubfields<Model>;

// What fun!
type AutoSaveToPathWithMaybeSubfields<
    Model,
    AutoSaveModel = ValidAutoSaveKeys<Model>,
> =
    AutoSaveModel extends Record<string, any>
        ? // If there are no subkeys that are valid
          [keyof AutoSaveModel] extends [never]
            ? // Then only this path is available
              AutoSaveToPath<Model>
            : // Otherwise we can save to this path plus any valid sub-paths.
              AutoSaveToPath<Model> & {
                  readonly [Key in keyof AutoSaveModel]-?: AutoSaveToPathWithMaybeSubfields<
                      NonNullable<AutoSaveModel>[Key]
                  >;
              }
        : AutoSaveToPath<Model>;

[keyof AutoSaveModel] extends [never] is handy pattern to check if an object has no keys.

2. Hey developer, a number is not a string!

The autoSave function uses method overloading to ensure that a transform function is required when the FormValue and OutputValue types don't match but optional when they do. This also becomes a compile time type check.

export function autoSave<FormValue extends OutputValue, OutputValue>(
    from: AutoSaveFromPath<FormValue>,
    to: AutoSaveToPath<OutputValue>,
    config?: NoInfer<AutoSaveConfig<FormValue, OutputValue>>
): void;
export function autoSave<FormValue, OutputValue>(
    from: AutoSaveFromPath<FormValue>,
    to: AutoSaveToPath<OutputValue>,
    config: NoInfer<RequiredTransformAutoSaveConfig<FormValue, OutputValue>>
): void;
export function autoSave<FormValue, OutputValue>(
    from: AutoSaveFromPath<FormValue>,
    to: AutoSaveToPath<OutputValue>,
    config?: NoInfer<AutoSaveConfig<FormValue, OutputValue>>
): void {
    const toNode = AutoSaveToPathNode.unwrapProxy(to);
    const fromNode = AutoSaveFromPathNode.unwrapProxy(from);

    fromNode.addAutoSave(toNode.path, config);
}

Great, but how does it save?

Now that we have node builder we need to make autoSaveForm construct effects that auto-save the requested fields.

AutoSaveHandler

The AutoSaveHandler is the heart of this functionality. At its core it is simply the effect from earlier but converted to an observable.

We use a manually managed effect instead of toObservable so that we can clean up the effect when the field is destroyed. toObservable works by creating an effect that is removed when the DestroyRef emits only.

The observable is also lazy and only creates the effect when subscribed to. share ensures that multiple subscribers to the observable will share the same effect.

A distinctUntilChanged is used to prevent auto-saving the same value multiple times if only the state of the field has changed.

function autoSaveHandler<FormValue, OutputValue, OutputModel>(
    field: FieldTree<FormValue>,
    autoSaveEntry: AutoSaveEntry<FormValue, OutputValue>,
    injector: Injector,
    config: AutoSaveFormConfig
): Observable<OutputModel> {
    return new Observable<FormValue | undefined>((subscriber) => {
        const autoSaveWatcher = effect(
            () => {
                if (shouldAutoSave(field)) {
                    const value = field().value();

                    untracked(() => subscriber.next(value));
                }
            },
            { injector, manualCleanup: true }
        );

        return () => {
            autoSaveWatcher.destroy();
        };
    }).pipe(
        takeUntilDestroyed(injector.get(DestroyRef)),
        map((value) => transform(value, autoSaveEntry.config?.transform)),
        // As a convience we allow transform to return undefined if the emission should be skipped
        filter((value): value is OutputValue => value !== undefined),
        // So we don't bother autoSaving the same value again. This will cause issues unless
        // form resets are handled (omitted from this example).
        distinctUntilChanged((previous, current) =>
            areOutputValuesEqual(previous, current, autoSaveEntryConfig?.equals)
        ),
        // Here we are converting from a path array into an object. This is built via the
        // autoSaveForm builder so we can be sure that this path is valid and matches the
        // transformed value.
        map((value) => createOutputModel(value, autoSaveEntry.path)),
        share()
    );
}

This is a simplified version of our AutoSaveHandler. The full implementation includes additional functionality to make it production ready.

  • An optional developer specified when.
  • Debouncing, although using Signal Form debouncing is preferred when possible.
    • Some complexity must be handled to cancel a debouncing auto-save emission when the field becomes invalid after a valid value has been added to the debounced queue.
  • Pristine value tracking so we don't auto-save the pristine value when the form first becomes dirty with the same value. Important for Signal Form debouncing since the field is marked as dirty before the new value is set.
  • Form resets so that the distinctUntilChanged doesn't swallow changes after a form reset.
    • Hint: Signal Forms don't support this directly, but we can track when the field becomes pristine after being dirty.

AutoSaveManager

The AutoSaveManager handles creating the needed AutoSaveHandler for the registered auto-save entries. This is a simple path traversal through the AutoSaveEntryNode tree and creating the AutoSaveHandler for each entry using the Field on that form path.

AutoSaveEntryNode is omitted from this example but is just a simple tree structure created from the root AutoSaveFromPathNode.

class AutoSaveManager {
    private readonly autoSaveHandlers = new Set<
        AutoSaveHandler<Model, any, any>
    >();

    // DynamicMergeObservable combines multiple observables into a single
    // observable either when first subscribed or when added to it after
    // the observable starts emitting. This will be important for later
    // when we add autoSaveEach.
    // The implementation is left as an exercise for the reader to draw
    // the rest of the owl. Hint: `mergeAll` is very useful.
    private readonly autoSaveOutputMerge = new DynamicMergeObservable<Model>();

    private readonly destroyRef = this.injector.get(DestroyRef);

    private readonly destroy$ = new Subject<void>();

    readonly autoSaveOutput$ = autoSaveOutputMerge.output$.pipe(
        share(),
        takeUntil(this.destroy$)
    );

    private readonly destroyRefWatcher = this.destroyRef.onDestroy(() =>
        this.destroy()
    );

    constructor(
        readonly root: AutoSaveEntryNode,
        readonly form: FieldTree<FormModel>,
        private readonly injector: Injector,
        private readonly config: AutoSaveFormConfig
    ) {
        /*
         Why is this in an effect with untracked?
         We need to wait for Angular to populate the required 
         inputs that might be used to create the initial form data. 
         We otherwise end up with 
         `NG0950: Input is required but no value is available yet`
         when we attempt to access the form before required inputs 
         are populated with their first values.
         */
        effect(
            () => {
                untracked(() => {
                    this.registerNodeAutoSaveTree(form, root);
                });
            },
            { injector }
        );
    }

    destroy(): void {
        // Destroy and clean up
    }

    private registerNodeAutoSaveTree<TFormModel>(
        field: FieldTree<TFormModel>,
        node: AutoSaveEntryNode
    ): void {
        for (const autoSaveEntry of node.autoSaveEntries) {
            // Create the autoSaveHandler and add it to the merged observables
        }

        // Recursively go through all the static form children
        if (node.children) {
            for (const [path, childNode] of node.children) {
                this.registerNodeAutoSaveTree(field[path], childNode);
            }
        }
    }
}

Note that there is some any types and casting in here as it is hard to get strict typing matching the AutoSaveEntryNode to the FieldTree. This is acceptable since the only public api into this system is through the strongly typed autoSaveForm builder.

Putting it all together

Putting it all together with the autoSaveForm function is straightforward. We create the root path nodes, call the autoSaveFn and then build the AutoSaveManager.

export function autoSaveForm<AutoSaveModel, FormModel>(
    form: FieldTree<FormModel>,
    autoSaveFn: NoInfer<AutoSaveFn<AutoSaveModel, FormModel>>,
    config?: {
        injector?: Injector;
    } & AutoSaveFormConfig
): Observable<AutoSaveModel> {
    const injector = config?.injector ?? inject(Injector);

    const toRoot = new AutoSaveToPathNode([], null);
    const fromRoot = new AutoSaveFromPathNode();

    autoSaveFn(
        fromRoot.pathProxy as AutoSaveFromPathTree<FormModel>,
        toRoot.pathProxy as AutoSaveToPathTree<AutoSaveModel>
    );

    // Omitted, just converts the `fromRoot` to an `AutoSaveEntryNode` graph
    // without the proxies.
    const autoSaveTree = new AutoSaveEntryNode(fromRoot);

    const autoSaveManager = new AutoSaveManager<AutoSaveModel, FormModel>(
        autoSaveTree,
        form,
        injector,
        config
    );

    return autoSaveManager.autoSaveOutput$;
}

Dynamic Form Arrays

Sometimes we want to auto-save a form field that is a dynamic array of field trees. For example, a list of addresses.

Signal Forms use applyEach to handle this use case. We can take a similar approach by creating an autoSaveEach builder which will observe the fields within each array element separately.

The AutoSaveFromPathNode is updated to include an arrayChild property so we can track these entries for special handling.

function autoSaveEach<FormValue>(
    from: AutoSaveFromPath<ReadonlyArray<FormValue>>,
    autoSaveFn: (from: AutoSaveFromPathTree<FormValue>) => void
) {
    const node = AutoSaveFromPathNode.unwrapProxy(from);
    const arrayNode = node.getOrCreateArrayChild();

    autoSaveFn(arrayNode.pathProxy as AutoSaveFromPathTree<FormValue>);
}

Auto-Saving Each

Signal Forms provide a reactive iterator on an array FieldTree. This is how you can use @for in a template with change detection. We can use this to itterate through the array in an effect and create a new AutoSaveManager for each array entry. Special care must be taken to destroy the AutoSaveManager for a removed array entry.

class AutoSaveEachHandler {
    private readonly autoSaveOutputMerge = new DynamicMergeObservable<Model>();

    private readonly autoSaveManagers = new Map<
        FieldTree<FormModel>,
        AutoSaveManager<Model, FormModel>
    >();

    private readonly destroy$ = new Subject<void>();

    private readonly unsubscribers = new Map<
        FieldTree<FormModel>,
        () => void
    >();

    private unsubscribeDestroyHandler = this.injector
        .get(DestroyRef)
        .onDestroy(() => {
            this.destroy();
        });

    readonly autoSaveOutput$ = this.autoSaveOutputMerge.output$.pipe(
        takeUntil(this.destroy$)
    );

    // The core piece here. This is a manually managed effect so we can
    // clean it up when needed. For example with multiple levels of nested arrays.
    protected fieldTreeChildWatcher = effect(
        () => {
            const childFieldTrees = this.gatherChildHandlers();

            untracked(() => {
                this.updateFromFieldTrees(childFieldTrees);
            });
        },
        { injector: this.injector }
    );

    destroy(): void {
        // Unsubscribe and destroy everything
    }

    private updateFromFieldTrees(fieldTrees: Set<FieldTree<FormModel>>): void {
        this.registerNewFieldTrees(fieldTrees);
        this.unregisterRemovedFieldTrees(fieldTrees);
    }

    private gatherChildHandlers(): Set<FieldTree<FormModel>> {
        for (const childFormField of this.form) {
            // Construct a set of all the child form fields and return it.
        }
    }

    private registerNewFieldTrees(fieldTrees: Set<FieldTree<FormModel>>): void {
        for (const fieldTree of fieldTrees) {
            // If we haven't created an AutoSaveManager for this fieldTree yet, then
            // create one and add it to the merged observable unsubscriber set.
        }
    }

    private unregisterRemovedFieldTrees(
        fieldTrees: Set<FieldTree<FormModel>>
    ): void {
        // Itterate through the currently tracked AutoSaveManagers and remove any
        // that are no longer in the set.
    }
}

Then we update the AutoSaveManager to handle the AutoSaveEntryNode.arrayChild by creating an AutoSaveEachHandler.

class AutoSaveManager {
    private readonly autoSaveEachHandlers = new Set<
        AutoSaveEachHandler<Model, any>
    >();

    destroy(): void {
        // Other destroy work

        // Clean up when this AutoSaveManager is removed such as when the array
        // entry that created this AutoSaveManager is destroyed.
        for (const autoSaveEachHandler of this.autoSaveEachHandlers) {
            autoSaveEachHandler.destroy();
        }

        this.autoSaveEachHandlers.clear();
    }

    private registerNodeAutoSaveTree<TFormModel>(
        field: FieldTree<TFormModel>,
        node: AutoSaveEntryNode
    ): void {
        // As before to recursively register the static fields

        if (node.arrayChild != null) {
            const autoSaveEachHandler = new AutoSaveEachHandler(
                node.arrayChild,
                field,
                this.injector,
                this.config
            );

            this.autoSaveEachHandlers.add(autoSaveEachHandler);
            this.autoSaveOutputMerge.add(autoSaveEachHandler.autoSaveOutput$);
        }
    }
}

This easily supports any level of array nesting.

Refining

This is a simplified framework that can be used to build a more complex implementation. Ours includes:

  • Debouncing.
  • Form resets.
  • Pristine value tracking.
  • Custom when conditions.
  • Form value equality checks.
  • Transformation helpers.

Usage

The autoSaveForm output stream is processed as needed by performing an action against each emission. The implementation is dependent on the area, but we often use a custom debounceMerge rxjs pipe operator to reduce the network traffic for quickly changing values.

We can use AutoSaveFn to define reusable auto-save schemas much like Signal Forms. For example:

const autoSaveSubForm: AutoSaveFn<SubFormModel, FormModel> = (from, to) => {
    // Auto-save the sub-form field
};

autoSaveForm<AutoSaveModel, FormModel>(this.myForm, (from, to) => {
    autoSaveForm(from.subForm, to.subForm);
});

Future Work and Considerations

Form submission

Auto-saving is great, but forms should also be able to submit and save as a whole. We have already defined our transformation schema, so building a way to reuse it on form submission would be a nice developer experience.

Partial Form Groups

Unlike Reactive Forms, Signal Forms don't exclude disabled fields of a FieldTree from the value(). We could potentially handle this when auto-saving a FieldTree.

Conclusion

We have evolved our auto-save framework to be more flexible and powerful using Signal Forms. This approach is a great fit for our use cases and is an example of how Signal Forms can be used to build powerful reactive forms.

Signal Forms are amazing and have a great developer experience. I'm all in on them now and grimace whenever I have to go back to the clunky Reactive Forms.