Form Lifecycle
Control form behavior throughout its lifecycle with hooks, state management, callbacks, and auto-save functionality.
Command Result Callbacks
Section titled “Command Result Callbacks”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> );}Available Callbacks
Section titled “Available Callbacks”| Callback | Parameters | When Invoked |
|---|---|---|
onSuccess | (response: TResponse) => void | Command executed successfully |
onFailed | (commandResult: CommandResult<TResponse>) => void | Command execution failed (any failure type) |
onException | (messages: string[], stackTrace: string) => void | Command threw an exception |
onUnauthorized | () => void | User is not authorized to execute the command |
onValidationFailure | (validationResults: ValidationResult[]) => void | Command failed validation |
Callback Invocation Order
Section titled “Callback Invocation Order”When a command fails, multiple callbacks may be invoked:
onFailed- Always called whenisSuccessisfalse- One or more specific callbacks based on failure type:
onExceptionifhasExceptionsistrueonUnauthorizedifisAuthorizedisfalseonValidationFailureifisValidisfalse
Type-Safe Response Handling
Section titled “Type-Safe Response Handling”CommandForm supports generic type parameters for type-safe responses:
// Define response typeinterface 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>Integration with Navigation
Section titled “Integration with Navigation”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> );}Before Execute Hook
Section titled “Before Execute Hook”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
trueto proceed,falseto cancel - Can modify the command before submission
- Can perform async operations
Form State Management
Section titled “Form State Management”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> );}Auto-Save
Section titled “Auto-Save”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> );}Reaching the Form From a Parent
Section titled “Reaching the Form From a Parent”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.
onStateChange — re-rendering the parent
Section titled “onStateChange — re-rendering the parent”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.