Building Forms in React: How to Handle State, Validation, API Calls, and Toasts

Ravindra NagRavindra Nag
Aug 13, 2024
4 min read

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.

What are we building?

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

  1. I created a Vite + React + TS app, you can stick to JS that's just preference.

  2. 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 馃構

56
Subscribe to my newsletter

Read articles from Ravindra Nag directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ravindra Nag
Ravindra Nag