Constantin Druccdruc

Hey, I'm Constantin Druc!

I'm a web developer sharing everything I know about building web applications.

I'm making a course on Laravel Sanctum: MasteringAuth.com

Simplify Vue Forms with this useForm composable

Most forms need a way to handle processing state and errors. Here's an example:

const router = useRouter();

const form = ref({
  title: "",
  content: "",
});

const isProcessing = ref(false);
const error = ref(null);

async function handleSubmit() {
  if (isProcessing.value) return;

  error.value = null;
  isProcessing.value = true;

  try {
    await axios.post("/api/posts", form.value);
    await router.push("/");
  } catch (err) {
    error.value = err;
  }

  isProcessing.value = false;

  if (error?.status === 403) {
    await router.replace("/login");
  }
}

We have an isProcesing boolean we use to track the form's processing state, and then an error object in which we store, and eventually display, any form-related errors.

This is ok but it makes a lot of noise and it's also a common source of bugs.

Forget to check isProcesing and now the user can submit the form while it is in a processing state.

Forget to reset the error and the erros will continue to show even after a successful form submission.

Forget to set isProcesing back to false and the form stays stuck in a procesing state.

This entire "is processing and error handling" logic is the same for all forms so it would be great to just move it in one place and re-use it whenever needed.

We can do that with a vue composable - which is just a regular js function making use of vue 3 features.

import {reactive} from "vue";

export default function useForm(fields) {
  const form = reactive({
    fields,
    error: null,
    processing: false,
    async submit(submitter) {
      if (this.processing) return;

      this.error = null;
      this.processing = true;

      try {
        await submitter(this.fields);
      } catch (err) {
        this.error = err;
      }

      this.processing = false;
    },
  });

  return form;
}

The above creates a reactive object that tracks the bits we want to extract: fields, error, and processing.

It also has a submit function that receives a submitter, and this is where everything happens. The structure of the function is exactly the same as the initial example; the only difference being that it abstracts the part that makes the requests into this submitter parameter.

The way you use the above composable is:

const router = useRouter();
const form = useForm({
  title: "",
  content: "",
});

async function handleSubmit() {
  await form.submit(async (fields) => {
    await axios.post("/api/posts", fields);
    await router.push("/");
  });

  if (form.error?.status === 403) {
    await router.replace("/login");
  }
}

You still have access to the form errors and processing state, but they are now tucked and handled away inside the form object.

Of course, this is just a starting point. You can choose to push the above composable as far as you want or need. You could add an "is dirty" functionality to check if the form has been changed, progress functionality when dealing with file uploads, reset form fields, reset form errors, and so on. There's no limit to how you can customize this!

Tags: