Introduction
After embarking on the many different projects here at Voltade, I have realized something they all have in common - tables and forms. In fact, as the title suggests, 95% of websites are just pretty forms and tables. Forms allow users to input data, and tables display that data in an organized manner. In this blog post, I’ll explore five essential tips I learnt to enhance your form handling skills, making your web applications more robust and user-friendly.
For these examples, we will be using the following tools:
- Zod
- Tanstack React Query
- Mantine UI Component Library
Tip 1: Using Zod with useForm
for Robust Validation
Validating user input is a critical aspect of form handling. Without proper validation, your application could accept incorrect or malicious data, leading to errors or security vulnerabilities. Zod is a TypeScript-first schema declaration and validation library that integrates seamlessly with form management libraries like Mantine's useForm
.
Why Use Zod with useForm
?
- Type Safety: Zod provides static type inference, ensuring that your form values match the expected types.
- Declarative Validation: Define your validation rules in a clear and maintainable way using Zod schemas.
- Error Handling: Easily display custom error messages based on validation failures.
- Integration: The
zodResolver
bridges Zod schemas withuseForm
, streamlining the validation process.
import { useForm, zodResolver } from '@mantine/form';
import { z } from 'zod';
// Zod schema for form validation
const lessonResourceSchema = z.object({
levelId: z.string().min(1, { message: 'Please select a level' }),
subjectId: z.string().min(1, { message: 'Please select a subject' }),
videoURL: z.string().url({ message: 'Please provide a valid URL' }), // URL validation
lessonFiles: z.array(z.instanceof(File)), // Validating an array of file inputs
});
type FormValues = z.infer<typeof lessonResourceSchema>;
function Component() {
const form = useForm<FormValues>({
initialValues: {
levelId: '',
subjectId: '',
videoURL: '',
lessonFiles: [],
},
validate: zodResolver(lessonResourceSchema), // Connecting Zod to Mantine form
});
return (
// Form JSX here
);
}
In this example, we define a Zod schema lessonResourceSchema
that specifies the validation rules for each form field. By using zodResolver
, we connect this schema to Mantine's useForm
, enabling automatic validation based on our schema. This approach ensures that all fields are validated consistently and elegantly, reducing the likelihood of validation errors slipping through.
Tip 2: Utilizing Dependent Queries with useQuery
When working with forms, you may encounter scenarios where certain fields depend on the values of other fields. For instance, you might want to fetch data from an API only after a user selects a specific option in a dropdown menu. This is where dependent queries come into play.
Why Use Dependent Queries?
- Efficiency: Prevent unnecessary API calls by only fetching data when required.
- User Experience: Reduce loading times and provide relevant options based on user input.
// The queryKey contains form.getValues().levelId and will refetch when levelId changes
const { data: classes } = useQuery({
enabled: form.values.levelId !== '',
queryKey: ['supabase', 'classes', form.getValues().levelId],
queryFn: async () => {
const { data, error } = await supabase
.from('classes')
.select('*, class_id: id, subject:subjects(id, name)')
.eq('level_id', form.getValues().levelId);
if (error) throw error;
return data;
},
select: (data) =>
// remove duplicate values
data.filter(
(value, index, self) =>
self.findIndex((t) => t.subject?.id === value.subject?.id) === index,
),
});
Tip 3: Prefer form.getValues
over form.values
When accessing form values within event handlers or effects, it's important to retrieve the most up-to-date values. While form.values
might seem convenient, it may not always reflect the latest state, especially in uncontrolled modes or before state updates.
Why Use form.getValues
?
- Accuracy: Always returns the most recent form values.
- Reliability: Avoids issues with stale or outdated data.
- Best Practice: Recommended by Mantine for consistent behavior.
import { useForm } from '@mantine/form';
const form = useForm({
mode: 'uncontrolled',
initialValues: { name: 'John Doe' },
});
const handleNameChange = () => {
form.setFieldValue('name', 'Test Name');
// ❌ Do not use form.values to get the current form values
// form.values has stale name value until next rerender in controlled mode
// and is always outdated in uncontrolled mode
console.log(form.values); // { name: 'John Doe' }
// ✅ Use form.getValues to get the current form values
// form.getValues always returns the latest form values
console.log(form.getValues()); // { name: 'Test Name' }
};
In the example above, after setting a new value for the name field, form.values still holds the old value until the next re-render. By using form.getValues(), we ensure that we're accessing the latest values immediately after an update. This practice prevents bugs that can occur due to stale state, especially in complex forms or when dealing with asynchronous operations.
Tip 4: Implementing Notifications on Success or Error
Providing feedback to users after they perform an action is crucial for a good user experience. Using Mantine’s notifications
in conjunction with React Query's useMutation
allows you to display elegant success or error messages based on the outcome of a mutation (e.g., form submission).
Why Use Notifications?
- User Feedback: Inform users about the success or failure of their actions.
- Professional UI: Mantine's notifications are stylish and customizable.
- Ease of Implementation: Integrates smoothly with mutation lifecycle methods.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
const queryClient = useQueryClient();
const createModuleMutation = useMutation({
mutationFn: async ({ name, description }: { name: string; description?: string; }) => {
const { data, error } = await supabase
.from('modules')
.insert({ class_id: classId, name, description })
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['supabase', 'modules', classId] });
notifications.show({
color: 'green',
message: `Successfully created module: ${data.name}`,
});
onCreate(data);
},
onError: (error: any) => {
notifications.show({
color: 'red',
message: `Error creating module: ${error.message}`,
});
},
});
In this code, we define a mutation using useMutation
to handle the creation of a new module. On success, we invalidate relevant queries to refresh the data and display a success notification. On error, we catch the exception and show an error notification with the message from the caught error. This approach provides immediate and clear feedback to the user, enhancing the overall user experience.
Tip 5: Passing Callback Functions as Props for Enhanced Communication
In React, parent and child components often need to communicate. One effective way to allow a child component to influence the state of a parent is by passing callback functions as props. This pattern enables the child component to execute functions defined in the parent, facilitating data flow and state management.
Why Pass Callbacks as Props?
- Decoupling: Keeps child components reusable and independent of parent state logic.
- State Management: Allows parent components to update their state based on child component actions.
- Adherence to React Principles: Follows React's unidirectional data flow for predictable state changes.
// Parent Component
const ParentComponent = () => {
const [moduleId, setModuleId] = useState();
const [opened, { open, close }] = useDisclosure();
const ingredients = // from some query
return (
<Modal opened={opened} onClose={close}>
<CreateModuleForm
ingredients={ingredients}
onCreate={(data) => setModuleId(data.id)}
/>
</Modal>
);
};
// Child Component
const CreateModuleForm = ({
ingredients,
onCreate,
}: {
ingredients: string;
// assume that Tables<'modules'> contains an id field
onCreate: (data: Tables<'modules'>) => void;
}) => {
// Component logic...
const createModuleMutation = useMutation({
mutationFn: async () => {
// Mutation logic...
return data
}
onSuccess: (data) => {
// Other success handling...
onCreate(data); // Invoking the parent's callback function
},
});
// JSX...
};
In this scenario, the ParentComponent
passes a callback function onCreate
to the CreateModuleForm
child component. When a new module is successfully created in the child component, it calls onCreate(data)
, passing the new module data back to the parent. The parent component then updates its moduleId
state with the new module's ID.
Benefits of This Technique:
- Decoupling: Child components don't need to know about the parent's state structure, making them more reusable.
- State Management: Parents can control state changes in response to child component events, keeping state management centralized.
- Data Flow: Maintains React's unidirectional data flow, where data flows down and actions flow up via callbacks.
When to Use This Pattern:
- Form Submissions: When a child form component needs to inform the parent about the submitted data.
- Event Handling: When actions in a child component (like button clicks) should trigger state changes in the parent.
- Dynamic Updates: When the child component generates or fetches data that the parent needs to utilize or display.
Final Note
By implementing these five tips, you can significantly enhance your form handling capabilities in web development. Utilizing robust validation with Zod, optimizing data fetching with dependent queries, accessing the latest form values, providing user feedback with notifications, and efficiently managing state between components are all practices that contribute to building better, more reliable web applications. Remember, effective form handling not only improves the developer experience but also leads to happier users.