Skip to content

Commit b8314bd

Browse files
committed
feat(forms): add experimental signal-based forms (angular#63408)
This commit introduces an experimental version of a new signal-based forms API for Angular. This new API aims to explore how signals can be leveraged to create a more declarative, intuitive, and reactive way of handling forms. The primary goals of this new signal-based approach are: * **Signal-centric Design:** Place signals at the core of the forms experience, enabling a truly reactive programming model for form state and logic. * **Declarative Logic:** Allow developers to define form behavior, such as validation and conditional fields, declaratively using TypeScript. This moves logic out of the template and into a typed, testable schema. * **Developer-Owned Data Model:** The library does not maintain a copy of data in a form model, but instead read and write it via a developer-provided `WritableSignal`, eliminating the need for applications to synchronize their data with the form system. * **Interoperability:** A key design goal is seamless interoperability with existing reactive forms, allowing for incremental adoption. * **Bridging Template and Reactive Forms:** This exploration hopes to close the gap between template and reactive forms, offering a unified and more powerful approach that combines the best aspects of both. This initial version of the experimental API includes the core building blocks, such as the `form()` function, `Field` and `FieldState` objects, and a `[control]` directive for binding to UI elements. It also introduces a schema-based system for defining validation, conditional logic, and other form behaviors. Note: This is an early, experimental API. It is not yet complete and is subject to change based on feedback and further exploration. Co-authored-by: Kirill Cherkashin <[email protected]> Co-authored-by: Alex Rickabaugh <[email protected]> Co-authored-by: Leon Senft <[email protected]> Co-authored-by: Dylan Hunn <[email protected]> Co-authored-by: Michael Small <[email protected]> PR Close angular#63408
1 parent a1d1cdf commit b8314bd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+12627
-1
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 580 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
"@rollup/plugin-commonjs": "^28.0.0",
8282
"@rollup/plugin-node-resolve": "^16.0.0",
8383
"@schematics/angular": "21.0.0-next.1",
84+
"@standard-schema/spec": "^1.0.0",
8485
"@types/angular": "^1.6.47",
8586
"@types/babel__core": "7.20.5",
8687
"@types/babel__generator": "7.27.0",
@@ -209,7 +210,8 @@
209210
"tslint-no-toplevel-property-access": "0.0.2",
210211
"typed-graphqlify": "^3.1.1",
211212
"undici": "^7.0.0",
212-
"vrsource-tslint-rules": "6.0.0"
213+
"vrsource-tslint-rules": "6.0.0",
214+
"zod": "^4.0.10"
213215
},
214216
"resolutions": {
215217
"https-proxy-agent": "7.0.6",

packages/forms/signals/BUILD.bazel

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
load("//tools:defaults.bzl", "api_golden_test", "ng_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_project(
6+
name = "signals",
7+
srcs = glob(
8+
[
9+
"*.ts",
10+
"src/**/*.ts",
11+
],
12+
),
13+
deps = [
14+
"//:node_modules/@standard-schema/spec",
15+
"//packages/core",
16+
"//packages/forms",
17+
],
18+
)
19+
20+
api_golden_test(
21+
name = "forms_signals_api",
22+
data = [
23+
"//goldens:public-api",
24+
"//packages/forms/signals",
25+
],
26+
entry_point = "index.d.ts",
27+
golden = "goldens/public-api/forms/signals/index.api.md",
28+
)

packages/forms/signals/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# 🚧 Experimental Signal-Based Forms API 🏗️
2+
3+
This directory contains an experimental new Angular Forms API built on top of
4+
[signals](https://angular.dev/guide/signals). We're using this experimental API to explore potential
5+
designs for such a system, to play with new ideas, identify challenges, and to demonstrate
6+
interoperability with the existing version of `@angular/forms`.
7+
8+
## Not yet supported
9+
10+
- Debouncing validation
11+
- Dynamic objects
12+
- Tuples
13+
- Interop with Reactive/Template forms
14+
- Strongly-typed binding to UI controls
15+
16+
## FAQs
17+
18+
### Why are you working on this?
19+
20+
We've been exploring ways that we can integrate signals into Angular's forms package. We've looked
21+
at several options, including integrating signals into template and reactive forms, and designing a
22+
new flavor of forms with signals at the core. Our hope is that we can leverage this work to close
23+
the gap between template and reactive forms, which often inspires debate in the Angular ecosystem.
24+
25+
### What does this mean for the future of template and/or reactive forms?
26+
27+
Nothing is changing yet with template and reactive forms. This API is early and still highly experimental.
28+
29+
Even if we achieve our goals, we will roll out any changes to forms incrementally. Like with NgModules
30+
and `standalone`, we don't intend to deprecate template or reactive forms without a clear sign from
31+
our community that the ecosystem is fully on board.
32+
33+
### Will I need to rewrite my application code to use the new forms system?
34+
35+
No - a non-negotiable design goal of a new signal-based forms system is interoperability with
36+
existing forms code and applications. It should be possible to incrementally start using the new
37+
system in existing applications, and as always we will explore the possibility of automated migrations.

packages/forms/signals/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
// This file is not used to build this module. It is only used during editing
10+
// by the TypeScript language service and during build for verification. `ngc`
11+
// replaces this file with production index.ts when it rewrites private symbol
12+
// names.
13+
14+
export * from './public_api';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/**
10+
* @module
11+
* @description
12+
* Entry point for all public APIs of this package.
13+
*/
14+
export * from './src/api/async';
15+
export * from './src/api/control';
16+
export * from './src/api/logic';
17+
export * from './src/api/property';
18+
export * from './src/api/structure';
19+
export * from './src/api/types';
20+
export * from './src/api/validators';
21+
export * from './src/controls/control';
22+
export * from './src/controls/interop_ng_control';
23+
export * from './src/api/validation_errors';
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
10+
import {computed, ResourceRef, Signal} from '@angular/core';
11+
import {FieldNode} from '../field/node';
12+
import {addDefaultField} from '../field/validation';
13+
import {FieldPathNode} from '../schema/path_node';
14+
import {assertPathIsCurrent} from '../schema/schema';
15+
import {property} from './logic';
16+
import {FieldContext, FieldPath, PathKind, TreeValidationResult} from './types';
17+
18+
/**
19+
* A function that takes the result of an async operation and the current field context, and maps it
20+
* to a list of validation errors.
21+
*
22+
* @param result The result of the async operation.
23+
* @param ctx The context for the field the validator is attached to.
24+
* @return A validation error, or list of validation errors to report based on the result of the async operation.
25+
* The returned errors can optionally specify a field that the error should be targeted to.
26+
* A targeted error will show up as an error on its target field rather than the field being validated.
27+
* If a field is not given, the error is assumed to apply to the field being validated.
28+
* @template TValue The type of value stored in the field being validated.
29+
* @template TResult The type of result returned by the async operation
30+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
31+
*/
32+
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (
33+
result: TResult,
34+
ctx: FieldContext<TValue, TPathKind>,
35+
) => TreeValidationResult;
36+
37+
/**
38+
* Options that indicate how to create a resource for async validation for a field,
39+
* and map its result to validation errors.
40+
*
41+
* @template TValue The type of value stored in the field being validated.
42+
* @template TParams The type of parameters to the resource.
43+
* @template TResult The type of result returned by the resource
44+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
45+
*/
46+
export interface AsyncValidatorOptions<
47+
TValue,
48+
TParams,
49+
TResult,
50+
TPathKind extends PathKind = PathKind.Root,
51+
> {
52+
/**
53+
* A function that receives the field context and returns the params for the resource.
54+
*
55+
* @param ctx The field context for the field being validated.
56+
* @returns The params for the resource.
57+
*/
58+
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
59+
60+
/**
61+
* A function that receives the resource params and returns a resource of the given params.
62+
* The given params should be used as is to create the resource.
63+
* The forms system will report the params as `undefined` when this validation doesn't need to be run.
64+
*
65+
* @param params The params to use for constructing the resource
66+
* @returns A reference to the constructed resource.
67+
*/
68+
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
69+
70+
/**
71+
* A function that takes the resource result, and the current field context and maps it to a list
72+
* of validation errors.
73+
*
74+
* @param result The resource result.
75+
* @param ctx The context for the field the validator is attached to.
76+
* @return A validation error, or list of validation errors to report based on the resource result.
77+
* The returned errors can optionally specify a field that the error should be targeted to.
78+
* A targeted error will show up as an error on its target field rather than the field being validated.
79+
* If a field is not given, the error is assumed to apply to the field being validated.
80+
*/
81+
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
82+
}
83+
84+
/**
85+
* Options that indicate how to create an httpResource for async validation for a field,
86+
* and map its result to validation errors.
87+
*
88+
* @template TValue The type of value stored in the field being validated.
89+
* @template TResult The type of result returned by the httpResource
90+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
91+
*/
92+
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
93+
/**
94+
* A function that receives the field context and returns the url or request for the httpResource.
95+
* If given a URL, the underlying httpResource will perform an HTTP GET on it.
96+
*
97+
* @param ctx The field context for the field being validated.
98+
* @returns The URL or request for creating the httpResource.
99+
*/
100+
readonly request:
101+
| ((ctx: FieldContext<TValue, TPathKind>) => string | undefined)
102+
| ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
103+
104+
/**
105+
* A function that takes the httpResource result, and the current field context and maps it to a
106+
* list of validation errors.
107+
*
108+
* @param result The httpResource result.
109+
* @param ctx The context for the field the validator is attached to.
110+
* @return A validation error, or list of validation errors to report based on the httpResource result.
111+
* The returned errors can optionally specify a field that the error should be targeted to.
112+
* A targeted error will show up as an error on its target field rather than the field being validated.
113+
* If a field is not given, the error is assumed to apply to the field being validated.
114+
*/
115+
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
116+
117+
/**
118+
* The options to use when creating the httpResource.
119+
*/
120+
readonly options?: HttpResourceOptions<TResult, unknown>;
121+
}
122+
123+
/**
124+
* Adds async validation to the field corresponding to the given path based on a resource.
125+
* Async validation for a field only runs once all synchronous validation is passing.
126+
*
127+
* @param path A path indicating the field to bind the async validation logic to.
128+
* @param opts The async validation options.
129+
* @template TValue The type of value stored in the field being validated.
130+
* @template TParams The type of parameters to the resource.
131+
* @template TResult The type of result returned by the resource
132+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
133+
*/
134+
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
135+
path: FieldPath<TValue, TPathKind>,
136+
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
137+
): void {
138+
assertPathIsCurrent(path);
139+
const pathNode = FieldPathNode.unwrapFieldPath(path);
140+
141+
const RESOURCE = property(path, (ctx) => {
142+
const params = computed(() => {
143+
const node = ctx.stateOf(path) as FieldNode;
144+
const validationState = node.validationState;
145+
if (validationState.shouldSkipValidation() || !validationState.syncValid()) {
146+
return undefined;
147+
}
148+
return opts.params(ctx);
149+
});
150+
return opts.factory(params);
151+
});
152+
153+
pathNode.logic.addAsyncErrorRule((ctx) => {
154+
const res = ctx.state.property(RESOURCE)!;
155+
switch (res.status()) {
156+
case 'idle':
157+
return undefined;
158+
case 'loading':
159+
case 'reloading':
160+
return 'pending';
161+
case 'resolved':
162+
case 'local':
163+
if (!res.hasValue()) {
164+
return undefined;
165+
}
166+
const errors = opts.errors(res.value()!, ctx as FieldContext<TValue, TPathKind>);
167+
return addDefaultField(errors, ctx.field);
168+
case 'error':
169+
// TODO: Design error handling for async validation. For now, just throw the error.
170+
throw res.error();
171+
}
172+
});
173+
}
174+
175+
/**
176+
* Adds async validation to the field corresponding to the given path based on an httpResource.
177+
* Async validation for a field only runs once all synchronous validation is passing.
178+
*
179+
* @param path A path indicating the field to bind the async validation logic to.
180+
* @param opts The http validation options.
181+
* @template TValue The type of value stored in the field being validated.
182+
* @template TResult The type of result returned by the httpResource
183+
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
184+
*/
185+
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(
186+
path: FieldPath<TValue, TPathKind>,
187+
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
188+
) {
189+
validateAsync(path, {
190+
params: opts.request,
191+
factory: (request: Signal<any>) => httpResource(request, opts.options),
192+
errors: opts.errors,
193+
});
194+
}

0 commit comments

Comments
 (0)