[](https://www.npmjs.com/package/@mmstack/form-core) [](https://github.com/mihajm/mmstack/blob/master/packages/form/core/LICENS
npm install @mmstack/form-core

@mmstack/form-core is an Angular library that provides a powerful, signal-based approach to building reactive forms. It offers a flexible and type-safe alternative to ngModel and Angular's built-in reactive forms, while leveraging the efficiency of Angular signals. This library is designed for fine-grained reactivity and predictable state management, making it ideal for complex forms and applications.
- Signal-Based: Fully utilizes Angular signals for efficient change detection and reactivity.
- Type-Safe: Strongly typed API with excellent TypeScript support, ensuring compile-time safety and reducing runtime errors.
- Composable Primitives: Provides formControl, formGroup, and formArray primitives that can be composed to create forms of any complexity.
- Predictable State: Emphasizes immutability and a clear data flow, making it easier to reason about form state.
- Customizable Validation: Supports synchronous validators with full type safety.
- Dirty and Touched Tracking: Built-in tracking of dirty and touched states for individual controls and aggregated states for groups and arrays.
- Reconciliation: Efficiently updates form state when underlying data changes (e.g., when receiving data from an API).
- Extensible: Designed to be easily extended with custom form controls and validation logic.
- UI Library Agnostic: form-core can be used with any UI library
1. Install @mmstack/form-core.
``bash`
npm install @mmstack/form-core
2. Start creating cool forms! :)
`typescript
import { Component } from '@angular/core';
import { formControl, formGroup } from '@mmstack/form-core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-user-form',
imports: [FormsModule],
template:
,
})
export class UserFormComponent {
name = formControl('', {
validator: () => (value) => (value ? '' : 'Name is required'),
});
age = formControl(undefined, {
//specify the type explicitely to have number type.
validator: () => (value) => (value && value > 0 ? '' : 'Age must be a positive number'),
});
}
`Slightly more complex example
`typescript
import { Component, computed, inject, Injectable, isDevMode, linkedSignal, Signal, signal, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { derived, formControl, FormControlSignal, formGroup, FormGroupSignal } from '@mmstack/form-core';
import { mutationResource, queryResource } from '@mmstack/resource';type Post = {
id: number;
title?: string;
body?: string;
};
@Injectable({
providedIn: 'root',
})
export class PostsService {
private readonly endpoint = 'https://jsonplaceholder.typicode.com/posts';
readonly id = signal(1);
readonly post = queryResource(
() => ({
url:
${this.endpoint}/${this.id()},
}),
{
keepPrevious: true,
cache: true,
},
); next() {
this.id.update((id) => id + 1);
}
prev() {
this.id.update((id) => id - 1);
}
private readonly createPostResource = mutationResource(
() => ({
url: this.endpoint,
method: 'POST',
}),
{
onMutate: (post: Post) => {
const prev = untracked(this.post.value);
this.post.set({ ...prev, ...post });
return prev;
},
onError: (err, prev) => {
if (isDevMode()) console.error(err);
this.post.set(prev); // rollback on error
},
onSuccess: (next) => {
this.post.set(next);
},
},
);
readonly loading = computed(() => this.createPostResource.isLoading() || this.post.isLoading());
createPost(post: Post) {
this.createPostResource.mutate({
body: post,
}); // send the request
}
updatePost(id: number, post: Partial) {
this.createPostResource.mutate({
body: { id, ...post },
url:
${this.endpoint}/${id},
method: 'PATCH',
}); // send the request
}
}type PostState = FormGroupSignal<
Post,
{
title: FormControlSignal;
body: FormControlSignal;
}
>;
function createPostState(post: Post, loading: Signal): PostState {
const value = signal(post);
return formGroup(value, {
title: formControl(derived(value, 'title'), {
label: () => 'Title',
readonly: loading,
validator: () => (value) => (value ? '' : 'Title is required'),
}),
body: formControl(derived(value, 'body'), {
label: () => 'Body',
readonly: loading,
validator: () => (value) => {
if (value && value.length > 255) return 'Body is too long';
return '';
},
}),
});
}
@Component({
selector: 'app-post-form',
imports: [FormsModule],
template:
>{{ formState().children().body.label() }}
,
})
export class PostFormComponent {
protected readonly svc = inject(PostsService);
protected readonly formState = linkedSignal
source: () => this.svc.post.value() ?? { title: '', body: '', id: -1, userId: -1 },
computation: (source, prev) => {
if (prev) {
prev.value.forceReconcile(source);
return prev.value;
}
return createPostState(source, this.svc.loading);
},
});
protected submit() {
if (untracked(this.svc.loading)) return;
const state = untracked(this.formState);
if (!untracked(state.valid)) return state.markAllAsTouched();
const value = untracked(state.value);
if (value.id === -1) this.svc.createPost(value);
else this.svc.updatePost(value.id, untracked(state.partialValue));
}
}
``
For an in-depth explanation of the primitives & how they work check out this article: Fun-grained Reactivity in Angular: Part 2 - Forms