Skip to content

Form Lifecycle

Control form behavior throughout its lifecycle with hooks, state management, callbacks, and auto-save functionality.

Handle command execution results with dedicated callbacks. These callbacks are invoked automatically after command execution based on the result state:

import { CommandForm } from '@cratis/arc/commands';
import { ValidationResult } from '@cratis/arc/validation';
interface CreateUserResponse {
userId: string;
message: string;
}
function UserForm() {
const handleSuccess = (response: CreateUserResponse) => {
console.log('User created with ID:', response.userId);
// Navigate to user profile, show success message, etc.
};
const handleFailed = (result: CommandResult<CreateUserResponse>) => {
console.error('Command failed:', result);
// Handle general failure
};
const handleException = (messages: string[], stackTrace: string) => {
console.error('Exception occurred:', messages);
// Log exception, show error dialog, etc.
};
const handleUnauthorized = () => {
console.warn('User is not authorized');
// Redirect to login, show authorization message, etc.
};
const handleValidationFailure = (validationResults: ValidationResult[]) => {
console.warn('Validation failed:', validationResults);
// Additional validation failure handling beyond automatic field errors
};
return (
<CommandForm<CreateUser, CreateUserResponse>
command={CreateUser}
onSuccess={handleSuccess}
onFailed={handleFailed}
onException={handleException}
onUnauthorized={handleUnauthorized}
onValidationFailure={handleValidationFailure}
>
<InputTextField<CreateUser> value={c => c.name} title="Name" required />
<InputTextField<CreateUser> value={c => c.email} type="email" title="Email" required />
<button type="submit">Create User</button>
</CommandForm>
);
}
CallbackParametersWhen Invoked
onSuccess(response: TResponse) => voidCommand executed successfully
onFailed(commandResult: CommandResult<TResponse>) => voidCommand execution failed (any failure type)
onException(messages: string[], stackTrace: string) => voidCommand threw an exception
onUnauthorized() => voidUser is not authorized to execute the command
onValidationFailure(validationResults: ValidationResult[]) => voidCommand failed validation

When a command fails, multiple callbacks may be invoked:

  1. onFailed - Always called when isSuccess is false
  2. One or more specific callbacks based on failure type:
    • onException if hasExceptions is true
    • onUnauthorized if isAuthorized is false
    • onValidationFailure if isValid is false

CommandForm supports generic type parameters for type-safe responses:

// Define response type
interface OrderResponse {
orderId: string;
orderNumber: string;
totalAmount: number;
}
// Use generic type parameters
<CommandForm<CreateOrder, OrderResponse>
command={CreateOrder}
onSuccess={(response) => {
// response is strongly typed as OrderResponse
console.log(`Order ${response.orderNumber} created with ID ${response.orderId}`);
}}
>
{/* Form fields */}
</CommandForm>

Common pattern for navigating after successful command execution:

import { useNavigate } from 'react-router-dom';
function CreateProjectForm() {
const navigate = useNavigate();
const handleSuccess = (response: ProjectResponse) => {
// Navigate to the newly created project
navigate(`/projects/${response.projectId}`);
};
return (
<CommandForm<CreateProject, ProjectResponse>
command={CreateProject}
onSuccess={handleSuccess}
>
<InputTextField<CreateProject> value={c => c.name} title="Name" required />
<TextAreaField<CreateProject> value={c => c.description} title="Description" />
<button type="submit">Create Project</button>
</CommandForm>
);
}

Execute code before the command is submitted:

function OrderForm() {
const handleBeforeExecute = async (command: CreateOrder): Promise<boolean> => {
// Confirm before submitting
const confirmed = window.confirm('Submit this order?');
if (!confirmed) {
return false; // Cancel submission
}
// Add calculated fields
command.totalAmount = calculateTotal(command.items);
command.submittedAt = new Date();
return true; // Proceed with submission
};
return (
<CommandForm
command={CreateOrder}
beforeExecute={handleBeforeExecute}
>
<InputTextField<CreateOrder> value={c => c.customerName} title="Customer" required />
{/* More fields... */}
</CommandForm>
);
}

The beforeExecute callback:

  • Receives the command instance
  • Returns true to proceed, false to cancel
  • Can modify the command before submission
  • Can perform async operations

Track form state for enhanced UX:

function SmartForm() {
const command = useCommandInstance(UpdateProfile);
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const handleExecute = async () => {
setIsSubmitting(true);
try {
const result = await command.execute();
if (result.isSuccess) {
setLastSaved(new Date());
}
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<CommandForm command={UpdateProfile}>
<InputTextField<UpdateProfile> value={c => c.name} title="Name" />
<InputTextField<UpdateProfile> value={c => c.email} type="email" title="Email" />
</CommandForm>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1rem' }}>
<button
onClick={handleExecute}
disabled={!command.hasChanges || isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
{lastSaved && (
<span style={{ fontSize: '0.875rem', color: '#6b7280' }}>
Last saved: {lastSaved.toLocaleTimeString()}
</span>
)}
{command.hasChanges && (
<span style={{ fontSize: '0.875rem', color: '#f59e0b' }}>
Unsaved changes
</span>
)}
</div>
</div>
);
}

Implement auto-save functionality:

function AutoSaveForm() {
const command = useCommandInstance(UpdateDraft);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (command.hasChanges) {
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout to auto-save after 2 seconds
timeoutRef.current = setTimeout(async () => {
await command.execute();
console.log('Auto-saved');
}, 2000);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [command.hasChanges]);
return (
<CommandForm command={UpdateDraft}>
<InputTextField<UpdateDraft> value={c => c.title} title="Title" />
<TextAreaField<UpdateDraft> value={c => c.content} title="Content" rows={10} />
<div style={{ fontSize: '0.875rem', color: '#6b7280', marginTop: '0.5rem' }}>
Changes are saved automatically
</div>
</CommandForm>
);
}

Hooks such as useIsCommandExecuting only work inside the form. When the submit button lives outside it — in a dialog footer, a page toolbar, a wizard’s navigation bar — the form exposes two props for that. They are complementary, and a parent that renders a button generally wants both.

formRef — executing the form and reading its state

Section titled “formRef — executing the form and reading its state”

formRef hands the parent a CommandFormHandle:

interface CommandFormState {
isExecuting: boolean;
isValid: boolean;
isAuthorized: boolean;
}
interface CommandFormHandle extends CommandFormState {
execute(): Promise<ICommandResult<unknown>>;
}

It is a named prop rather than a forwarded ref, because forwarding erases the generic arguments and callers write <CommandForm<TCommand, TResponse> … />.

import { useRef } from 'react';
import { CommandForm, type CommandFormHandle } from '@cratis/arc.react/commands';
function ProfilePanel() {
const formRef = useRef<CommandFormHandle>(null);
return (
<>
<CommandForm<UpdateProfile> command={UpdateProfile} formRef={formRef}>
<InputTextField<UpdateProfile> value={c => c.name} title="Name" />
</CommandForm>
<footer>
<button onClick={() => formRef.current?.execute()}>Save</button>
</footer>
</>
);
}

The handle object is created once for the lifetime of the form, so it never changes identity. React still re-attaches a ref whenever the ref itself changes identity, though — so pass a stable one. An object ref from useRef, or a callback wrapped in useCallback with no dependencies, attaches once on mount and detaches once on unmount however often the parent re-renders. An inline formRef={handle => setHandle(handle)} is a new function on every render and therefore re-attaches on every render; it still works, but it does redundant attach/detach work, and pairing it with a state setter is a re-render loop.

Its state members are getters over live values, so a handle captured once never reports stale state.

A ref is not reactive: reading formRef.current.isExecuting in an event handler is fine, but it cannot make the parent re-render, so a button cannot disable itself from the handle alone. onStateChange is the reactive half — it is called whenever execution, validity or authorization changes:

function ProfilePanel() {
const formRef = useRef<CommandFormHandle>(null);
const [state, setState] = useState<CommandFormState>({
isExecuting: false,
isValid: false,
isAuthorized: true
});
return (
<>
<CommandForm<UpdateProfile>
command={UpdateProfile}
formRef={formRef}
onStateChange={setState}>
<InputTextField<UpdateProfile> value={c => c.name} title="Name" />
</CommandForm>
<footer>
<button
onClick={() => formRef.current?.execute()}
disabled={state.isExecuting || !state.isValid || !state.isAuthorized}>
{state.isExecuting ? 'Saving…' : 'Save'}
</button>
</footer>
</>
);
}

An inline arrow function is safe here — the callback is read through a ref, so re-creating it on every render does not re-fire it. The callback is invoked on mount with the form’s initial state and then on every change, never for a parent re-render on its own.

isExecuting counts executions rather than flagging them, so it stays true until the last overlapping submission settles, and a rejected command gives its count back — see Working with Hooks.