The Problem with Forms

avatar
Simon MacDonald
August 25, 2022

forms Photo by Scott Graham on Unsplash

Forms have problems! Specifically, user input can have problems, so we need to validate it. Worse, users can be malicious, so we need to validate their input on the backend because they can always bypass the client code.

The form

For our somewhat contrived example, we’ll build a form that takes an email address as an input and saves it in a database. We’ll report a problem if the email is invalid or exists in the database.

<form method="post" action="/data">
  <label for="email">
    Email
    <input type="email" name="email"/>
  </label>
  <button>Submit</button>
</form>

The POST handler

Even though we’ve used an email input field to ensure that we get a valid email address, we make doubly sure that the user didn’t bypass the form and we use a regular expression to validate our input is an email address again. Then we search all the email addresses currently stored in the database to ensure it hasn’t already been registered.

async function http (req) {
 const { email } = req.body
 let problem = ''

 if (!/\S+@\S+\.\S+/.test(email)) {
   problem = 'Not a valid email address'
 }

 let emails = await data.get({ table: 'emails' })
 if (emails && emails.find(item => item.email === email)) {
   problem = 'This email already exists in the database'
 } else {
   await data.set({ table: 'emails', email })
 }

 return {
   location: '/'
 }
}

Finally we redirect back to the form page which is fine when there are no issues but what if we detect a problem?

The problem with problems

A web consumer submitted a form POST to our backend, and we found some problems. We could respond immediately with the form values, Rails does this, but it can create issues. The safe way to handle a form POST is to always respond with a 302 or 303 redirect. The nuance of this is somewhat documented here. The problem with redirecting is that we’ve now lost all the form state sent to us in the request body, as well as the list of problems with that state.

The solution for problems

One way to solve this is to persist the request body form state along with the list of problems so we can display it all in the context of the original form they submitted. A common place to persist this information is in a cookie-based session.

  1. Form submits
  2. Backend finds problems so it responds with { session: { problems }, location: '/path/to/form' }
async function http (req) {
 const { email } = req.body
 let problem = ''

 if (!/\S+@\S+\.\S+/.test(email)) {
   problem = 'Not a valid email address'
 }

 let emails = await data.get({ table: 'emails' })
 if (emails && emails.find(item => item.email === email)) {
   problem = 'This email already exists in the database'
 } else {
   await data.set({ table: 'emails', email })
 }

 return {
   session: { problem },
   location: '/'
 }
}

  1. Browser redirects to /path/to/form and displays the form with values from req.session.problems
async function http (req) {
 let { problem = ' '} = req.session

 return {
   statusCode: 200,
   headers: {
     'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
     'content-type': 'text/html; charset=utf8'
   },
   body: `
<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Architect</title>
</head>
<body class="padding-32">
 <div class="max-width-320">
   <div class="margin-left-8">
     <h1 class="margin-bottom-16">
       Email Collection Form
     </h1>
     <form method="post" action="/data">
       <label for="email">
         Email
         <input type="email" name="email"/>
       </label>
       <p>${problem}</p>
       <button>Submit</button>
     </form>
   </div>
 </div>
</body>
</html>
`
 }
}

Fiddly bits

The session needs to be cleared of problems when you display the form. Architect session is a one-way write-only trip, so to preserve keys, you’ll want to pull the problems off the session while preserving other stuff in there. let { problems, …session } = req.session does the trick. Another thing that can help is to scope problems under the form’s name. That way, an app with multiple forms won’t show problems from other forms.

async function http (req) {
 let { problem = '', …session} = req.session

 return {
   statusCode: 200,
   session,
   body: …
 }
}

The other fiddly bit is the original form values. If I fill out a large form with a tonne of fields and discover there was an issue, you best at least persist the valid values for me. So you need to send that state with the form problem messages as well.

Send the originally submitted email back to the form.

async function http (req) {
 const { email } = req.body
 let problem = ''return {
   session: { email, problem },
   location: '/'
 }
}

Read the form fields from the session and re-populate the form.

async function http (req) {
 let { email = '', problem = '', …session} = req.session

 return {
   statusCode: 200,
   session:,
   headers: {
     'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
     'content-type': 'text/html; charset=utf8'
   },
   body: `
<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Architect</title>
</head>
<body class="padding-32">
 <div class="max-width-320">
   <div class="margin-left-8">
     <h1 class="margin-bottom-16">
       Email Collection Form
     </h1>
     <form method="post" action="/data">
       <label for="email">
         Email
         <input type="email" name="email" value="${email}"/>
       </label>
       <p>${problem}</p>
       <button>Submit</button>
     </form>
   </div>
 </div>
</body>
</html>
`
 }
}

In Conclusion

So there you have it—a solution for reporting problems with form submissions, even with a full page refresh. No client-side JavaScript or framework is needed. Everything is built upon web standards and can be progressively enhanced to better the user experience.