This week I needed to make a really big form. It’s really big. Most people don’t like forms, but I’ve always been a bit of a contrarian, and my approach for saving user input on this form got me thinking about how much effort goes into things people take for granted. In code, I mean. Life is way out of scope for a blog post.
The general principle here is that you’re updating a load of quantities in a form, to build an order of sorts. The fields need to update on keypress, but they also need one final update on blur, to be sure.
Now we have two problems here: firstly, we don’t want to be hitting our server every time someone types something. Your customer might type “1”, or “12”, or “120” - I don’t want to update my order 3 times here if I don’t have to, or the guy who wrote my backend (me) is going to yell at me. And secondly, our updates can come from two places. We don’t want them crashing or double-updating.
The following flowchart roughly demonstrates the lifecycle of a user input in a single field, all the way to it being successfully updated. I’ve annotated this with numbers so I can refer to specific steps.
- If they keypress, the first thing we do is cancel any running timers. This might seem a bit backwards, but it will make sense on the next step.
- We start a 500ms timer. What I’m saying is “if the user stops typing for half a second, assume they’ve either stopped typing or are checking to see what happens”. The reason we stop the timer in step 2 is so that we don’t create duplicate timers each time someone types something. We only need to know when typing has stopped.
- If the timer runs to 0, a callback is triggered. At this point, we have converged with blur - we’re saying “commit whatever has been typed so far”, so we can begin to share code.
- The very first thing we do, again, might seem backwards as you read it the first time, but will make sense later. Firstly, we need to check if an update is already happening. If the update is already happening, we don’t want to send again because we don’t want to overload our server, or end up confusing it with two very similar things happening in parallel, so if we’re already updating, we start another timer. This timer will wait for the update to finish.
- This timer’s first job is to wait for a bit. I set this to one second.
- Once this timer expires, the first thing it does is check to see if the update we’re waiting for is done. If it’s not, we wait again. If it is, we assume the path is clear and we get ready to send our updated data to the server.
- But! There’s a chance that if the user is properly done typing, that this update is just going to send the same value that the last one sent. That’s pointless, so we do a quick check to see if the value that we’re about to submit is the same as the last value submitted. I tend to find that servers, like people, get annoyed if you pointlessly tell them the same things over and over.
- If the value hasn’t changed, we have nothing more to do here right now, so we exit.
- The value has changed, so we’re going to send it to the server
- The server accepted our update, and sent us back some data to update our state and UI
And that’s it. If it seems complicated, that’s because it kinda is. For something which, on the surface, might be “well I just typed the value - save it”, there’s a lot more to making it a pleasant and robust experience for your user.
And here’s some example code that I’ve put together to demonstrate this flowchart. You can skip it if that bores you.
One thing I haven’t included in here, which I should, is onbeforeunload. This is something that I should be running when my timers run to help ensure that the user waits for my asynchronous calls to run before closing their browser tab or navigating away.
The main reason this isn’t part of this code, is this isn’t where I’d do it in practice. It’s out of scope for the controller of a single field, of which there are many on the page, to control whether tab-closing is blocked. There should be a controller that listens for timers being started, and then adds its own callbacks if any timer in its domain is running. If this is a Field controller, you would also have a FieldGroup controller which listens for Fields that start timers, and if any of its Fields have timers, it blocks the tab from closing. Then when its last Field timer has run out, and no-one is updating anything, it’s now safe to close the tab.
Of course, the user can just plough through this anyway, but the main hope is that the time it takes the user to process the popup they see, allows the browser enough time to finish all of its requests, rendering it somewhat redundant and simultaneously incredibly effective! Funny how that works.
By the way, I love Stimulus
Am I wrong? Is there a better way to do this that doesn't involve installing some npm package? Let's talk.