Go back to Joren.co

Autosave forms with the Vue.js composition API

While I still feel the need to press Cmd/Ctrl - S every few seconds, a lot of apps have autosave functionality implemented. Here's how to do this in Vue.js. I'll be using the Vue.js composition API.

This is the example we're going to work with today. A form with a title field, text field and a submit button. When submitting the form, the data is posted to a blog-posts endpoint. The api is fake, but illustrates the use of an HTTP client. It could be replaced by Axios or Ky (my personal favorite). The important thing is that api.post returns a promise (it will become clear why later).

<script setup>
import { reactive } from 'vue';
import api from './api';

const form = reactive({
title: '',
text: '',
});

const handleSubmit = () => {
api.post('blog-posts', form);
};
</script>

<template>
<form @submit.prevent="handleSubmit">
<input v-model="form.title" />
<textarea v-model="form.text"></textarea>
<button>Save</button>
</form>
</template>

The most primitive way of autosaving is saving whenever the form changes. We can do this by listening to the input event on the entire form (alternatively we could use watchers).

<script setup>
// ...
const handleChange = () => {
api.post('blog-posts', form);
};
</script>

<template>
<form @submit.prevent="handleSubmit" @input="handleChange">
<!-- ... -->
</form>
</template>

As you can imagine, this is not really optimal. With every keystroke the form will be saved, resulting in an unnecessarily high amount of requests. It would be better if the form is saved whenever the user is done typing.


This is where debounce comes into play. The debounce method takes two arguments. The first one is a delay in milliseconds, the second one is a callback function. Debounce returns a new function that we can use as a better change handler.

<script setup>
import { debounce } from 'throttle-debounce';
// ...

const handleChange = debounce(250, () => {
api.post('blog-posts', form);
});
</script>

When called, our new handleChange function will not execute the callback immediately, it will start a timer for 250ms and execute the callback when the timer finishes.

When called a second time with the timer still running, it will reset the timer to 0, forget about the first call and execute the last call when the timer finishes. This means that if we type 4 letters really fast, our form will be saved 250ms after typing the fourth letter.


Debounce takes care of restricting the amount of requests, but it doesn't wait for previous requests to finish before starting a new one. This could cause some weird bugs.

Typing 3 words ("one two three"), with a pause after every word, results into 3 requests. Even though the order of the requests is correct, the finishing order is not guaranteed. If the second request finishes after the third request, "one two" would be stored in our database instead of "one two three".

A sketch of how the result of the code would look.

To make sure a request never starts before another one finishes we can use p-queue, a promise queue with concurrency control. We will initialize PQueue with a concurrency of 1. We can now add functions returning a promise to the queue. P-queue will make sure that there's only one promise running at the same time.

<script setup>
import PQueue from 'p-queue';
// ...

const queue = new PQueue({ concurrency: 1 });

const handleChange = debounce(250, () => {
queue.clear();
queue.add(() => api.post('blog-posts', form));
});
</script>

The queueing system solves our issue, but we can improve our code by clearing the queue with queue.clear() before adding a task. There's no point in sending old data to the api when we already have more recent data available.

Consider the following example.

  1. A user types "one". A task is being added to the queue and starts executing immediately.
  2. The user continues to type, "one two". Another task is added to the queue, but this time it is not executing yet since the first task is not finished.
  3. Finally the user completes the sentence, "one two three". The second task (waiting to be executed) is removed by queue.clear() and a new task is added to the queue, ready to be executed when the first task finishes.
A sketch of how the result of the code would look.

You can play around with the complete example on StackBlitz.

In the next blog post we're going to refactor everything into a reusable composable, stay tuned!