In this lesson, we will implement comprehensive form validation across all three steps of the trip logging process (Start Trip, Catch Log, and End Trip). This ensures users provide accurate, complete information before proceeding to each subsequent step. We'll add validation logic using React state management and display user-friendly error messages using USWDS ErrorMessage components.
Before implementing validation, let's understand the validation patterns used in RADFish applications and how they integrate with React state management.
Form validation in RADFish applications follows a consistent pattern across all form components:
- Validation Functions: Pure functions that check field values against business rules
- Error State Management: React state to store and display validation errors
- Submission Prevention: Block navigation/submission when validation fails
- User Experience: Clear, accessible error messages using USWDS components
Our trip logging application uses several validation types:
- Required Field Validation: Ensures essential fields are not empty
- Data Type Validation: Validates numbers, coordinates, and other specific formats
- Range Validation: Ensures numeric values fall within acceptable bounds
- Business Logic Validation: Custom rules specific to fishing trip data
Key Validation Concepts:
- Client-Side Validation: Immediate feedback without server round-trips
- Accessible Error Messages: Screen reader compatible with proper ARIA attributes
- Form State Management: Coordinating validation with React's controlled components
- Progressive Enhancement: Validation works even if JavaScript fails
Let's begin by implementing validation for the Start Trip form, which collects the trip date, start time, and weather conditions.
The Start Trip form uses React state to store validation errors. When the form is submitted, we need to validate all fields and update the error state.
Open src/pages/StartTrip.jsx and locate the handleSubmit function.
const newErrors = validateForm();
const handleSubmit = async (e) => {
e.preventDefault(); // Prevent default browser form submission
setSubmitted(true); // Mark form as submitted to show errors
//diff-add-start
const newErrors = validateForm();
setErrors(newErrors);
//diff-add-endExplanation:
validateForm(): Calls the validation function that checks all form fieldssetErrors(newErrors): Updates React state with the validation results- The form only proceeds if
Object.keys(newErrors).length === 0
The Start Trip form validates three required fields using a standardized validation helper:
const validateRequired = (value, fieldName) => {
if (!value || String(value).trim() === "") {
return `${fieldName} is required`;
}
return null;
};
// Validates all fields in the form
const validateForm = () => {
const newErrors = {};
const dateError = validateRequired(formData.tripDate, FIELD_DATE);
if (dateError) newErrors.tripDate = dateError;
const weatherError = validateRequired(formData.weather, FIELD_WEATHER);
if (weatherError) newErrors.weather = weatherError;
const timeError = validateRequired(formData.startTime, FIELD_START_TIME);
if (timeError) newErrors.startTime = timeError;
return newErrors;
};Understanding the Validation Pattern:
- Helper Function:
validateRequired()provides reusable validation logic - Field Constants:
FIELD_DATE,FIELD_WEATHER, etc. ensure consistent error messages - Error Object: Returned object maps field names to error messages
- Null Returns: Valid fields return
nullto indicate no errors
The Start Trip form displays validation errors using USWDS ErrorMessage components. Each form field has an associated error display:
//diff-remove-start
<FormGroup>
//diff-remove-end
//diff-add-start
<FormGroup error={submitted && errors.tripDate}>
//diff-add-end
<Label
htmlFor="tripDate"
//diff-add-start
error={submitted && errors.tripDate}
//diff-add-end
hint=" mm/dd/yyyy"
requiredMarker
>
Date
</Label>
<DatePicker
id="tripDate"
name="tripDate"
defaultValue={formData.tripDate}
onChange={handleDateChange}
//diff-add-start
validationStatus={submitted && errors.tripDate ? "error" : undefined}
//diff-add-end
aria-describedby="trip-date-hint trip-date-error-message"
required
/>
<span id="trip-date-hint" className="usa-sr-only">
Please enter or select the date of your fishing trip.
</span>
//diff-add-start
{submitted && errors.tripDate && (
<ErrorMessage id="trip-date-error-message" className="font-sans-2xs">
{errors.tripDate}
</ErrorMessage>
)}
//diff-add-end
</FormGroup>Understanding the Error Display Pattern:
The diff shows the transformation from a basic form group to a fully validated one:
- FormGroup Error State:
error={submitted && errors.tripDate}applies USWDS error styling to the entire form group - Label Error Integration:
error={submitted && errors.tripDate}on the Label component provides visual error indication - Input Validation Status:
validationStatus={submitted && errors.tripDate ? "error" : undefined}adds error styling to the DatePicker - ARIA Accessibility:
aria-describedby="trip-date-hint trip-date-error-message"links the input to both hint text and error messages - Conditional Error Messages:
{submitted && errors.tripDate && (...)}only displays errors after form submission - Consistent ID Naming:
id="trip-date-error-message"follows a consistent kebab-case pattern for error message identification
This pattern ensures errors are visually prominent, accessible to screen readers, and only appear when users attempt to submit invalid data.
The Catch Log page presents a more complex validation scenario with both a "new catch" form and a list of existing catches that can be edited.
The "Add Catch" form validates species, weight, length, time, and optional coordinates before adding to the list.
Open src/pages/CatchLog.jsx and locate the handleAddCatch function. Observe how validation is implemented:
const handleAddCatch = async (e) => {
e.preventDefault();
setSubmitted(true);
//diff-add-start
const formErrors = validateForm();
setErrors(formErrors);
//diff-add-end
// Proceed only if no errors and tripId exists
if (Object.keys(formErrors).length === 0 && tripId) {
// Save catch and update UI
}
};Complex Validation Rules:
The Catch Log uses multiple validation types beyond simple required field checks:
const validateNumberRange = (value, min, max, fieldName, allowZero = true) => {
if (value === "" || value === null || value === undefined) return null;
const numValue = Number(value);
if (isNaN(numValue)) return `${fieldName} must be a valid number`;
if (!allowZero && numValue <= min)
return `${fieldName} must be greater than ${min}`;
if (allowZero && numValue < min)
return `${fieldName} must be at least ${min}`;
if (numValue > max) {
const minOperator = allowZero ? ">=" : ">";
return `${fieldName} must be ${minOperator} ${min} and <= ${max}`;
}
return null;
};
const validateLatitude = (value) => {
if (value === "" || value === null || value === undefined) return null;
const numValue = Number(value);
if (isNaN(numValue)) return `${FIELD_LATITUDE} must be a valid number`;
if (numValue < -90 || numValue > 90)
return `${FIELD_LATITUDE} must be between -90 and 90`;
return null;
};Advanced Validation Features:
- Range Validation: Weight (0-1000 lbs) and length (0-500 inches) must be within realistic bounds
- Coordinate Validation: Latitude (-90 to 90) and longitude (-180 to 180) follow geographic standards
- Optional Field Handling: Coordinates are validated only if values are provided
- Type Coercion: Automatic conversion from string inputs to numbers
Before navigating to the End Trip page, all catches in the "Recorded Catches" list must be validated:
const handleSubmit = async (e) => {
e.preventDefault();
//diff-add-start
const recordedErrors = validateRecordedCatches();
setRecordedCatchErrors(recordedErrors);
//diff-add-end
// Only proceed if there are no errors in the recorded catches list
if (Object.keys(recordedErrors).length === 0) {
// Navigate to EndTrip page
navigate(`/end`, { state: { tripId: tripId } });
}
};Multi-Record Validation:
const validateRecordedCatches = () => {
const allErrors = {};
catches.forEach((catchItem, index) => {
const catchErrors = {};
// Validate each catch using the same rules as new catches
catchErrors.species = validateRequired(catchItem.species, FIELD_SPECIES);
catchErrors.weight = validateRequired(catchItem.weight, FIELD_WEIGHT);
// ... additional validations
// Only store errors if they exist
const filteredCatchErrors = Object.entries(catchErrors).reduce(
(acc, [key, value]) => {
if (value) acc[key] = value;
return acc;
},
{},
);
if (Object.keys(filteredCatchErrors).length > 0) {
allErrors[index] = filteredCatchErrors;
}
});
return allErrors;
};The recorded catches list displays errors with index-specific identifiers:
catches.map((catchItem, index) => {
const catchErrors = recordedCatchErrors[index] || {};
return (
<div key={catchItem.id || index}>
//diff-remove-start
<FormGroup>
//diff-remove-end
//diff-add-start
<FormGroup error={!!catchErrors.species}>
//diff-add-end
<Label
htmlFor={`recorded-species-${index}`}
//diff-add-start
error={!!catchErrors.species}
//diff-add-end
>
Species
<span className="text-secondary-vivid margin-left-05">*</span>
</Label>
<Select
id={`recorded-species-${index}`}
//diff-add-start
validationStatus={catchErrors.species ? "error" : undefined}
aria-describedby={`species-hint recorded-species-${index}-error-message`}
//diff-add-end
// ... other props
>
{/* options */}
</Select>
<span id="species-hint" className="usa-sr-only">
Please select the species of the catch.
</span>
//diff-add-start
{catchErrors.species && (
<ErrorMessage id={`recorded-species-${index}-error-message`}>
{catchErrors.species}
</ErrorMessage>
)}
//diff-add-end
</FormGroup>
</div>
);
})Now it's your turn! Apply the validation patterns you've learned to the End Trip form. This form needs validation for the trip's end time and weather conditions before allowing users to review their complete trip data.
However, before we can add validation, we need to complete the End Trip form implementation since it's currently missing the required fields and components.
First, you need to enable the endTime and endWeather fields in the data model.
Your task is to add the endTime and endWeather fields to the trip model.
:::info Hint
Models are defined in src/index.jsx.
:::
:::warning Multi-Step Form Consideration
Since this is a multi-step form, you need to handle the fact that endTime and endWeather won't be available when the trip is first created in the StartTrip form.
The Problem: If you make these fields required: true in the model, the StartTrip form will fail validation when trying to create the initial trip record because these fields don't exist yet.
The Solutions:
- Option 1: Make the fields optional in the model (
required: false) - Option 2: Set them to empty strings when creating the initial trip
Recommended Approach: Use Option 2 and update the Form.create():
// Look for the Form.create call around line 241 and update it to:
await Form.create({
id: newTripId,
...tripData,
//diff-add-start
endTime: "",
endWeather: ""
//diff-add-end
});This allows the trip to be created successfully at the start, and these fields will be populated later in the EndTrip form. :::
Open src/pages/EndTrip.jsx and add the missing component imports.
Your task: Add the necessary imports at the top of the file.
Components you'll need:
:::info Hint
Look at src/pages/StartTrip.jsx to see what components are needed for:
- Time input field
- Weather dropdown
- Error message display :::
The End Trip form currently only has navigation buttons. You need to add the actual form fields.
Fields to add:
- End Time field - similar to the startTime field in Start Trip
- End Weather field - similar to the weather field in Start Trip
:::info Hint You'll need:
- FormGroup with proper error state
- Label with required marker and error state
- Input component with validation status
- Conditional ErrorMessage component :::
You'll need to add state management for the form data and handle changes.
:::info Hint You'll need to add the following:
- formData state for storing field values
- handleTimeChange function for time picker
- handleSelectChange function for dropdowns
- Form initialization from database/localStorage :::
Finally, add the validation patterns you learned from the previous forms.
For validation functions:
:::info Hint
- Copy the validateRequired helper from
src/pages/StartTrip.jsx - Add validateForm function that checks both endTime and endWeather
- Use the same field constants pattern (FIELD_END_TIME, FIELD_END_WEATHER) :::
For setting validation errors:
:::info Hint
- Use the same pattern as Start Trip
- const newErrors = validateForm(); :::
For displaying error messages:
:::info Hint
- Follow the same pattern as previous forms
- Remember to:
- Add error prop to FormGroup
- Add error prop to Label
- Add validationStatus to the input component
- Add conditional ErrorMessage component
- Use proper aria-describedby for accessibility :::
Test the validation system with both invalid and valid data to ensure it works correctly:
-
Start Trip (
/start):- Try submitting empty form → verify error messages appear
- Fill fields correctly → proceeds to Catch Log
-
Catch Log (
/catch):- Try "Add Catch" with empty fields → verify validation errors
- Test invalid coordinates (latitude > 90) → verify range validation
- Add valid catches → try "Next" with invalid recorded catches → verify list validation
- Ensure all catches are valid → proceeds to End Trip
-
End Trip (
/end):- Try submitting without end time/weather → verify validation blocks navigation
- Fill fields correctly → proceeds to Review page
Expected Results:
- ✅ Errors appear only after submission attempts
- ✅ Invalid data blocks navigation between steps
- ✅ Error styling applied consistently across all forms
- ✅ Valid data clears errors and allows progression
You have successfully implemented comprehensive form validation across all three steps of the trip logging process!
Key Benefits:
- Data quality: Ensures complete, accurate trip information
- User experience: Clear guidance when data doesn't meet requirements
- Accessibility: Screen reader compatible with proper labeling
- Progressive enhancement: Validation works even if JavaScript fails
In the next lesson, we'll implement the review page where users can see all their trip data before final submission.