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.
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:
-
First, the query to match the document we want to update
{ title: 'tester' }
. We just force this totester
because it's just dummy data. -
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 theinput
object. -
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 theapi.set()
we hinted at earlier up in ourdata
function. The difference is that theset()
we're importing from@joystick.js/ui
is intended for the client/browser only, while theapi.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.