Skip to content

Form Validation

Use when: a form has non-trivial cross-field validation rules, async field-level checks (e.g. “username already taken”), or multi-step workflows that share state across steps. Don’t use when: a form is a simple uncontrolled HTML form or a library like React Hook Form already owns it — adding a Cubit on top creates two sources of truth.

Keep field values and a touched map in state. Derive validation errors as a getter — they recompute on every read and can never go stale. Show errors only for touched fields so the initial load is clean.

class
class RegisterCubit
RegisterCubit
extends
class Cubit<S extends object = any, Args = void, Deps extends object = Record<string, never>>

Cubit<S> is a StateContainer<S> with emit / patch exposed as public mutation surface. Today it adds nothing structurally beyond StateContainer — both are inherited from the underlying StructuralContainer<S>. Kept as a real class (not a type alias) because downstream code does instance instanceof Cubit checks (see A2 audit).

The class body is intentionally empty: a no-op emit override would still go through applyState, and patch is inherited from StructuralContainer (path-diffed, microtask-flushed). If a caller needs the old "skip if no real change" patch semantics, they can wrap patch themselves or call emit after a manual equality check.

Cubit
<
interface RegisterState
RegisterState
> {
constructor() {
super({
RegisterState.email: string
email
: '',
RegisterState.password: string
password
: '',
RegisterState.confirmPassword: string
confirmPassword
: '',
RegisterState.touched: Partial<Record<"email" | "password" | "confirmPassword", boolean>>
touched
: {},
RegisterState.submitStatus: "idle" | "loading" | "success" | "error"
submitStatus
: 'idle',
RegisterState.submitError: string | null
submitError
: null,
});
}
// ── field setters ──────────────────────────────────────────────────────
RegisterCubit.setEmail: (email: string) => void
setEmail
= (
email: string
email
: string) => this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
email?: string | undefined
email
});
RegisterCubit.setPassword: (password: string) => void
setPassword
= (
password: string
password
: string) => this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
password?: string | undefined
password
});
RegisterCubit.setConfirmPassword: (confirmPassword: string) => void
setConfirmPassword
= (
confirmPassword: string
confirmPassword
: string) =>
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
confirmPassword?: string | undefined
confirmPassword
});
/** Mark a field as interacted with so its error becomes visible. */
RegisterCubit.touchField: (field: keyof RegisterState["touched"]) => void

Mark a field as interacted with so its error becomes visible.

touchField
= (
field: "email" | "password" | "confirmPassword"
field
: keyof
interface RegisterState
RegisterState
['touched']) => {
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined
touched
: { ...this.
StructuralContainer<RegisterState>.state: RegisterState
state
.
RegisterState.touched: Partial<Record<"email" | "password" | "confirmPassword", boolean>>
touched
, [
field: "email" | "password" | "confirmPassword"
field
]: true } });
};
// ── derived validation ─────────────────────────────────────────────────
/** All errors keyed by field name (always computed, never stored). */
get
RegisterCubit.errors: Partial<Record<"email" | "password" | "confirmPassword", string>>

All errors keyed by field name (always computed, never stored).

errors
():
type Partial<T> = { [P in keyof T]?: T[P] | undefined; }

Make all properties in T optional

Partial
<
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<'email' | 'password' | 'confirmPassword', string>
> {
const {
const email: string
email
,
const password: string
password
,
const confirmPassword: string
confirmPassword
} = this.
StructuralContainer<RegisterState>.state: RegisterState
state
;
const
const errors: Partial<Record<"email" | "password" | "confirmPassword", string>>
errors
:
type Partial<T> = { [P in keyof T]?: T[P] | undefined; }

Make all properties in T optional

Partial
<
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<'email' | 'password' | 'confirmPassword', string>
> = {};
if (!
const email: string
email
.
String.includes(searchString: string, position?: number): boolean

Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions that are greater than or equal to position; otherwise, returns false.

@paramsearchString search string

@paramposition If position is undefined, 0 is assumed, so as to search all of the String.

includes
('@'))
const errors: Partial<Record<"email" | "password" | "confirmPassword", string>>
errors
.
email?: string | undefined
email
= 'Enter a valid email address.';
if (
const password: string
password
.
String.length: number

Returns the length of a String object.

length
< 8)
const errors: Partial<Record<"email" | "password" | "confirmPassword", string>>
errors
.
password?: string | undefined
password
= 'Password must be ≥ 8 characters.';
if (
const confirmPassword: string
confirmPassword
!==
const password: string
password
)
const errors: Partial<Record<"email" | "password" | "confirmPassword", string>>
errors
.
confirmPassword?: string | undefined
confirmPassword
= 'Passwords do not match.';
return
const errors: Partial<Record<"email" | "password" | "confirmPassword", string>>
errors
;
}
get
RegisterCubit.isValid: boolean
isValid
() {
return
var Object: ObjectConstructor

Provides functionality common to all JavaScript objects.

Object
.
ObjectConstructor.keys(o: {}): string[] (+1 overload)

Returns the names of the enumerable string properties and methods of an object.

@paramo Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.

keys
(this.
RegisterCubit.errors: Partial<Record<"email" | "password" | "confirmPassword", string>>

All errors keyed by field name (always computed, never stored).

errors
).
Array<string>.length: number

Gets or sets the length of the array. This is a number one higher than the highest index in the array.

length
=== 0;
}
// ── submit ─────────────────────────────────────────────────────────────
RegisterCubit.submit: () => Promise<void>
submit
= async () => {
// Touch all fields so every error surfaces on an attempted submit.
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined
touched
: {
email?: boolean | undefined
email
: true,
password?: boolean | undefined
password
: true,
confirmPassword?: boolean | undefined
confirmPassword
: true },
});
if (!this.
RegisterCubit.isValid: boolean
isValid
) return;
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
submitStatus?: "idle" | "loading" | "success" | "error" | undefined
submitStatus
: 'loading',
submitError?: string | null | undefined
submitError
: null });
try {
await
const api: {
register(email: string, password: string): Promise<void>;
}
api
.
function register(email: string, password: string): Promise<void>
register
(this.
StructuralContainer<RegisterState>.state: RegisterState
state
.
RegisterState.email: string
email
, this.
StructuralContainer<RegisterState>.state: RegisterState
state
.
RegisterState.password: string
password
);
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
submitStatus?: "idle" | "loading" | "success" | "error" | undefined
submitStatus
: 'success' });
} catch (
function (local var) e: unknown
e
) {
// ⚠️ Do NOT include the raw password in any error log or analytics event.
this.
StateContainer<RegisterState, void, Record<string, never>>.patch(partial: {
email?: string | undefined;
password?: string | undefined;
confirmPassword?: string | undefined;
touched?: {
email?: boolean | undefined;
password?: boolean | undefined;
confirmPassword?: boolean | undefined;
} | undefined;
submitStatus?: "idle" | "loading" | "success" | "error" | undefined;
submitError?: string | null | undefined;
}): void

Override of StructuralContainer.patch that routes through the StateContainer concerns: disposed guard, dev-only emit-rate check, _changedWhileHydrating flag, pending-change capture (so legacy listeners and stateChanged system events see the merged prev/next), and the registry-level stateChanged notification. We still call super.patch so path-marking semantics (the whole point of patch) are preserved.

patch
({
submitStatus?: "idle" | "loading" | "success" | "error" | undefined
submitStatus
: 'error',
submitError?: string | null | undefined
submitError
:
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
function (local var) e: unknown
e
) });
}
};
}
function RegisterForm() {
const [state, form] = useBloc(RegisterCubit);
const errors = form.errors;
const { touched } = state;
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.submit();
}}
>
<label>
Email
<input
type="email"
value={state.email}
onChange={(e) => form.setEmail(e.target.value)}
onBlur={() => form.touchField('email')}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
</label>
<label>
Password
<input
type="password"
value={state.password}
onChange={(e) => form.setPassword(e.target.value)}
onBlur={() => form.touchField('password')}
/>
{touched.password && errors.password && <span>{errors.password}</span>}
</label>
<label>
Confirm password
<input
type="password"
value={state.confirmPassword}
onChange={(e) => form.setConfirmPassword(e.target.value)}
onBlur={() => form.touchField('confirmPassword')}
/>
{touched.confirmPassword && errors.confirmPassword && (
<span>{errors.confirmPassword}</span>
)}
</label>
{state.submitError && <p role="alert">{state.submitError}</p>}
<button type="submit" disabled={state.submitStatus === 'loading'}>
{state.submitStatus === 'loading' ? 'Submitting…' : 'Register'}
</button>
</form>
);
}

For async checks (username availability, coupon validation), pattern them like a debounced async action — see Debounce — and merge the result into the errors object or a separate asyncErrors field.

  • Cubitpatch, emit, getters
  • Patterns — named instances for billing/shipping form sections
  • Debounce — async field-level validation