tutorial // Dec 16, 2022

How to Implement a Simple Autosave Feature in JavaScript

How to implement a UI that saves to the database automatically as the user makes changes.

How to Implement a Simple Autosave Feature in JavaScript

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Wiring up a simple API on the server

To start, we're going to begin by creating two pseudo API endpoints that we can call to save our user's data to the database and then fetch for display in the UI. By default, Joystick—the framework we used above to create our app—uses MongoDB as its database, so we'll use that to keep our work on the server quick.

To rig up our API endpoints, we're going to use Joystick's getters and setters. Getters get data from our database and Setters set data in our database. Syntactically they're nearly identical, and behind the scenes their implementation is identical (the difference in naming is purely for the sake of adding context to our code).

Let's open up the existing file at /api/index.js created for us when we ran joystick create app above and making a few changes:

Terminal

export default {
  getters: {
    post: {
      get: (input, context = {}) => {
        return context.mongodb.collection('posts').findOne();
      },
    },
  },
  setters: {
    savePost: {
      set: (input = {}, context = {}) => {
        return context.mongodb.collection('posts').updateOne({
          title: 'tester',
        }, {
          $set: {
            content: input?.content,
          },
        }, {
          upsert: true,
        });
      },
    },
  },
};

Two things here. First, the object exported from this file represents the "schema" for our API. It has two objects defined on it: getters, which contains all of the getter definitions for the API and setters which contains all of the setter definitions for the API.

Focusing on getters, we define a getter called post who's job will be to fetch our test post from the database. To define it, we create a property post on our getters object and assign that to its own object with a function get (Joystick expects this behind the scenes). That function, get(), receives two arguments: input which is any input we've passed from the client and context an object containing multiple things including access to our database drivers, information about the current HTTP request being fulfilled by the endpoint, and if applicable, the current user.

From that context object, here, we call to context.mongodb to access the MongoDB Node.js driver (behind the scenes, Joystick connects to our MongoDB database and makes the driver available to our app). Because we're just going to create a single dummy post, here, we just saying from the .collection('posts'), call the .findOne() method. Because we only expect their to be one post, we skip passing a query to .findOne().

Next, down in the setters object, we define a setter called savePost. Notice that here, instead of a get() function, we define a set() function (again, the behavior is identical to get(), however, we use different names to add context to the intent of our code).

Inside, again, from the context object, we access the mongodb driver, however this time, we call to the .updateOne() method and pass it three arguments:

  1. First, the query to match the document we want to update { title: 'tester' }. We just force this to tester because it's just dummy data.

  2. Second, the update operation we want to perform { $set: { content: input?.content } } to say we want to update the content to whatever we passed from the client via the input object.

  3. Third, an options object that specifies how we want to perform the update. In this case, we set upsert: true to say "if the post doesn't exist create it, and if it does exist, just update it."

That's all we need on the server! If you're curious, you can see that the object being exported from /api/index.js is imported in /index.server.js and passed to the options for the node.app() function that starts up our app (Joystick expects the api object to contain our API schema, if we have one).

Next up, let's move down to the client and wire up our debounce function.

Wiring up a debounce function

In a user interface, "debouncing" describes the act of deferring the call of a function until a later time, based on some condition. To avoid overwhelming our server with an update on every single keystroke by our user, now, we want to define a function we can call that will wrap some code we only want to run after our user has stopped typing for some amount of time.

/lib/debounce.js

export default (() => {
  let timer = 0;
  return (callback, ms) => {
    clearTimeout(timer);
    timer = setTimeout(callback, ms);
  };
})();

Pay close attention. From this file, we're exporting our debounce function. To do it, we export a function that calls itself automatically (using the IIFE, or, Imediately Invoked Function Expression syntax of (() => { ... })()). Inside of that function, we define a mutable variable with let called timer which contains the ID of the setTimeout timer we'll use to defer a call to the callback function we pass to our debounce() function.

We do this because we need a way to maintain a reference to the existing timer in memory—if we don't do this, the timer will just "hang around" in memory until its timeout ms are reached and then get called. This is the "secret sauce" behind this function. Notice that we're returning another function which expects two arguments: callback and ms. To jump ahead quick, this is what a call to our function will look like:

debounce(() => {
  // The code we want to run here...
}, 1000);

Notice that this syntax is identical to a normal setTimeout() function in JavaScript. This is because what we're effectively creating is a setTimeout() that cancels itself if it's set again before its ms timeout has been reached. If we look at that function we're returning, the first thing we do is call to clearTimeout() passing the timer variable we created outside the function (this is why we define it outside the function, we need a persistent reference to it in memory).

Next, we overwrite timer with a new setTimeout(), doing nothing more than relaying our callback and ms. That's all we need.

Now, when we call debounce() like we showed above, if the original timer we set hasn't been fulfilled, it will be canceled and overwritten with a new setTimeout(). This will continue until we stop calling debounce(). When we do that for the amount of ms (milliseconds) we've passed, the setTimeout() will not be canceled and is called as expected.

To tie this all together, let's wire this into a dummy UI with a form for our post content and see how to make it work.

Implementing the autosave functionality

To wrap up, now, we're going to put our API and debounce() function to use. To start, we're going to up the file created for us earlier at /ui/pages/index/index.js and add the following:

import ui, { set } from '@joystick.js/ui';
import debounce from '../../../lib/debounce';

const Index = ui.component({
  data: async (api = {}) => {
    return {
      post: await api.get('post'),
    };
  },
  state: {
    saving: false,
  },
  css: `
    div {
      padding: 40px;
    }

    label {
      display: flex;
      font-size: 16px;
      margin-bottom: 10px;
    }

    label span {
      display: inline-block;
      margin-left: auto;
      color: #aaa;
    }

    input,
    textarea {
      display: block;
      width: 100%;
      box-sizing: border-box;
      padding: 20px;
      border: 1px solid #ddd;
      resize: none;
      margin-bottom: 30px;
    }

    textarea {
      height: calc(100vh - 250px);
    }
  `,
  render: ({ state, data }) => {
    return `
      <div>
        <label>Title</label>
        <input readonly type="title" placeholder="Title" value="Test" />
        <label>Content ${state?.saving ? '<span>Saving...</span>' : ''}</label>
        <textarea name="content" placeholder="Write your post here...">${data?.post?.content || ''}</textarea>
      </div>
    `;
  },
});

export default Index;

Above, we're defining a Joystick component which renders a dummy form for performing our content editing.

In the render() function, we return some HTML to build out our form (a read only title input and textarea where our user can type in their content). Of note, if we look close we're using two variables in here state and data. If we look at the render() function's definition, we destructure or "pluck off" state and data from the component instance passed to our render() function automatically by Joystick.

Here, state refers to the temporary internal data object or "state" of our UI. If you look up further in the component, you can see this state object being initialized with a property saving set to false. Down in our render() we reference this state.saving value to conditionally show a message to our users letting them know we're saving their changes.

The data variable here (which we're using to get the default value for our textarea by grabbing the data.post.content value) is populated automatically by Joystick via the data property we defined higher up on our component. This is a special function in Joystick that's designed to fetch data for our component during the server-side rendering process.

If we look at that function, we prefix it with the async keyword so that we can use the await keyword inside of it (without this, JavaScript will throw a runtime error saying that we can't use await without flagging the parent context as async). Looking at the actual function definition, we take in an object api which is provided by Joystick and gives us access to a server-safe copy of the get() and set() methods we use to call our getters and setters in our API.

Here, we return an object from our data function with one property post, set to the result of calling api.get('post'). As you might expect, this calls our post getter which will return the result of our .findOne() call on the posts collection in MongoDB.

Once this is fetched, Joystick automatically pipes the resulting data down to our render() function—technically, all functions on the component that have access to the component instance can get to this—where we reference the data.post.content variable.

Before we move on to the important part, real quick, we've also added some css to style up the contents of our form. This has zero-bearing on the functionality we're implementing and is purely for aesthetics.

Now, we're ready to wire up our autosave. Let's pull in all of the code we'll need and then step through it.

/ui/pages/index/index.js

import ui, { set } from '@joystick.js/ui';
import debounce from '../../../lib/debounce';

const Index = ui.component({
  data: async (api = {}) => { ... },
  state: {
    saving: false,
  },
  css: `...`,
  events: {
    'keyup [name="content"]': (event, component) => {
      debounce(() => {
        component.setState({ saving: true }, () => {
          set('savePost', {
            input: {
              content: event?.target?.value,
            },
          }).then(() => {
            setTimeout(() => {
              component.setState({ saving: false });
            }, 1500);
          });
        });
      }, 500);
    },
  },
  render: ({ state, data }) => {
    return `
      <div>
        <label>Title</label>
        <input readonly type="title" placeholder="Title" value="Test" />
        <label>Content ${state?.saving ? '<span>Saving...</span>' : ''}</label>
        <textarea name="content" placeholder="Write your post here...">${data?.post?.content || ''}</textarea>
      </div>
    `;
  },
});

export default Index;

Above, we've added a new property to our component events which allows us to define event listeners on our component. To events, we pass an object where each property defines an event listener we'd like to establish. The key or property name defines the type of event we want to listen for and the CSS selector we want to listen for that event on. Here, we want to listen for a keyup event on elements with a name attribute equal to content (our textarea).

To that property, we pass a function which will get called whenever a keyup event is detected on our textarea. To that function, Joystick passes the native DOM event and the component instance. If we look inside, we're calling to our debounce() function which we've imported up top from /lib/debounce.js.

Remember: our debounce() function is in essence a glorified setTimeout(). Here, we call it by passing the callback we want to fire as the first argument after the ms have passed in the second argument (here, 500 or "half a second"). Again, this callback function will only be called if the full 500 ms is allowed to pass before debounce() is called again.

Inside of the callback, we first make a call to component.setState() to set our saving value to true so that we can show the Saving... message down in our render() function. Inside of the callback passed to that (this callback only fires after Joystick has updated state and re-rendered the UI), we make a call to the set() function we've imported from @joystick.js/ui up top.

Not to confuse, the set() function here is slightly different from the api.set() we hinted at earlier up in our data function. The difference is that the set() we're importing from @joystick.js/ui is intended for the client/browser only, while the api.set() is designed to work on the server. Though they both ultimately serve the same purpose, under the hood they're set up differently to avoid runtime errors.

To it, we pass savePost, the name of our setter we built out earlier, along with an input option set to an object containing the content value we expect on the server. In this case, we just set this to event.target.value to get the current value of the textarea where this event is being triggered, or, the event.target.

Finally, because we expect set() to return a JavaScript Promise, we chain on a call to .then() which is called once our set() request successfully completes. Inside the callback passed to that, we set a timeout for 1500 milliseconds and once that's elapsed, set saving back to false. Though not technically necessary, the setTimeout() here creates a visual buffer for the user so that they see the Saving... message render in the UI. Without this setTimeout, the response is too fast and we never actually see the message.

That's it! If we go ahead and type into our input, we should see our Saving... text appear and if we check our database, we'll see that our post updates automatically.

Wrapping up

In this tutorial, we learned how to wire up an autosave feature in our app. We learned how to write a debounce function that helped us to defer sending data to the database, and then, how to call that function based on a user's input in a form. Finally, we learned how to persist data in the database, saving changes automatically to prevent data loss.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode