Building Forms in React: How to Handle State, Validation, API Calls, and Toasts
"Work smarter, not harder." โ Allen F. Morgenstern
Hi there, today I am sharing my knowledge of form building in React.
Very often, we have found ourselves writing a bunch of useState
or even worse, using a global state management library like Redux to manage form values, errors, loading states, etc. We now have tools that make form management in React a very easy task.
This blog is not just a formik / react-hook-form tutorial, I want to cover everything from writing jsx to validating inputs, to handling form submissions, and finally toast the user notifying them of their success or failure. Let's begin :)
What are we building?
Just a single page that lists all the posts from the JSONPlaceholder API, with a form to add a new post. On submitting the form, the API returns the created post which we will append to the already fetched list of posts.
Choosing libraries
Let's not re-invent the wheel for our task. Assuming you already know how state works in React, let's not use useState
to store our form data. Same goes for validation, let's not write our own validation logic in our form's onSubmit
handler. I will use react-hook-form for form state and zod for validation. You can choose formik or yup as well.
For API calls, I will use axios and @tanstack/react-query. JSONPlaceholder as the backend :)
I will use sonner as my toast component since it's easy to use and looks good stock.
(Optional) I will also add Material UI because it has pre-built components with decent styles.
Project Setup
I created a Vite + React + TS app, you can stick to JS that's just preference.
Installed all the required dependencies.
Config
We will configure axios and react-query first:
// src/config/axios.ts
import axios from "axios";
const api = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com'
})
export default api
// src/config/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient()
export default queryClient
Then we wrap our app with QueryClientProvider
// src/main.ts
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
Queries and Mutations
@tanstack/react-query
's useQuery
and useMutation
hooks require a queryFn
and a mutationFn
respectively. Let's get them ready:
// src/utils/query/posts.ts
import api from "@/config/axios";
import { PostDto } from "@/dtos/posts";
export const getAllPosts = () => api.get<PostDto[]>('/posts')
getAllPosts.getQueryKey = () => ['posts', 'all']
I like to define a getQueryKey
function for the query functions, makes it easier to work with useQuery
.
// src/utils/mutations/posts.ts
import api from "@/config/axios";
import { CreatePostDto, PostDto } from "@/dtos/posts";
export const createNewPost = (body: CreatePostDto) =>
api.post<PostDto>("/posts", body);
The Home Page
const PostListCreate = () => {
const { data } = useQuery({
queryFn: getAllPosts,
queryKey: getAllPosts.getQueryKey()
})
return (
<PageContainer padding={4} gap={2}>
<CreatePostForm />
<Typography variant="overline" color='grey.900'>Latest Posts</Typography>
<Divider />
{data && <PostGrid posts={data.data} />}
</PageContainer>
)
}
Very simple page, nothing serious here
The Form Component
First, let's define the form validation schema with zod:
const validationSchema = z.object({
title: z.string().min(1, "Title is required"),
body: z.string().min(1, "Body is required"),
});
type FormData = z.infer<typeof validationSchema>;
Second, write JSX:
const CreatePostForm = () => {
const { register, handleSubmit, formState, reset } = useForm<FormData>({
resolver: zodResolver(validationSchema),
});
const { mutateAsync } = useMutation({
mutationFn: createNewPost,
});
const onSubmit = (data: FormData) => {
...
};
return (
<Stack component="form" gap={2} onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register("title")}
label="Title"
error={Boolean(formState.errors.title)}
helperText={formState.errors.title?.message}
/>
<TextField
{...register("body")}
label="Body"
error={Boolean(formState.errors.body)}
helperText={formState.errors.body?.message}
multiline
minRows={5}
/>
<Button variant="contained" type="submit" sx={{ alignSelf: "end" }}>
Post
</Button>
</Stack>
);
};
Great! now we have a form to look at. Let's finish the onSubmit
function:
const onSubmit = (data: FormData) => {
const payload = {
...data,
userId: "1",
};
toast.promise(
mutateAsync(payload).then((res) => {
// reset the form to accept new submissions
reset();
// After a successful response, you can just refresh the getAllPosts query to update the list
// But this does not work in case of JSONPlaceholder
// queryClient.invalidateQueries({
// queryKey: getAllPosts.getQueryKey()
// })
// Adding the newly created post to the post list manually to simulate a refresh
const prevData: AxiosResponse<PostDto[]> = queryClient.getQueryData(
getAllPosts.getQueryKey(),
)!;
const newData = {
...prevData,
data: [res.data, ...prevData.data],
};
queryClient.setQueryData(getAllPosts.getQueryKey(), newData);
return res;
}),
{
loading: "Creating your post...",
success: "Post created",
error: (err) => {
console.error("error: ", err);
return err.message;
},
},
);
};
Here I am using sonner
's toast.promise
function that dynamically changes the toast based on the promise's status.
And that's it :) We have a very nice form that shows validation, makes api request, and renders a toast. All of that without any useState
.
GitHub repo for this project
Thank you for reading! I am very excited to publish my first blog on Hashnode! Please let me know your thoughts in the comments ๐