DEV Community

Marvin
Marvin

Posted on • Originally published at mfrachet.com

Astro: progressively enhanced forms

If you want to read the full article with code snippet highlighted correctly on the author's blog, follow this link

From MDN documentation:

Progressive enhancement is a design philosophy that provides a baseline of essential content and functionality to as many users as possible, while delivering the best possible experience only to users of the most modern browsers that can run all the required code.

One great example of progressive enhancement is form handling in Remix.

In Remix, you can create an almost regular HTML form, a function called action, and there you go: the form works with and without JavaScript.
It means that people disabling JavaScript in their browsers, or under specific constraints regarding their devices (or browsers, or low-connectivity regions) can still use the form and benefit from the feature.

Some people can stand that it's probably not necessary, and I agree. The thing is: it's free. It's baked in Remix and works using the same API.

// Copy/pasted from the Remix doc
// about action https://remix.run/docs/en/main/route/action
export async function action({ request }: ActionFunctionArgs) {
  const body = await request.formData();
  const title = body.get("title");
  const todo = await fakeCreateTodo({ title });

  return redirect(`/todos/${todo.id}`);
}

export default function AddTodo() {
  return (
    <Form method="post">
      <input type="text" name="title" />
      <button type="submit">Create Todo</button>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's a minimal API that provides a great experience to the users.

Remix is great, but what about Astro?

As you may already know, Astro is a framework for server side rendering that provides the ability to integrate frontend frameworks to get interactivity in the client. It is not the same kind of (meta) framework than Remix or Nextjs.
And as pointed on their homepage, the framework focuses on content websites for now.

Basically, regarding froms, it means: use regular HTML forms or the things you have available on your client side framework (e.g: formik on React, or whatever alternative) or build something yourself.

We are going to try building something as close to what Remix provides as possible knowing that we have the following constraint: we only build user-lands. We can't tweak the inner framework implementations to accomodate our needs.

It's important to have that in mind. We'll be forced to add a bit of verbosity along the way.

Let's get into it

I assume you already have an Astro project running locally. If that's not the case, you can create one from the get started page.

Create an HTML form

The first step is to create a regular HTML form that we will enhance later:

---
// index.astro
---
<main>
  <h1>Welcome</h1>
  <form method="post">
    <label>
      Email
      <input type="email" name="email" />
    </label>

    <label>
      Password
      <input type="password" name="password" />
    </label>

    <button>Sign in</button>
  </form>
</main>
Enter fullscreen mode Exit fullscreen mode

Handle the form on the backend

This will be the progressive enhancement "baseline": a form that works with regular HTML, validated and handled in the backend so that as many users as possible can use it.

Additional note: this works without JavaScript on the client.

---
// index.astro

let error: string | undefined;

if (Astro.request.method === "POST") {
  // process form handling
  const data = await Astro.request.formData();
  const email = data.get("email")?.toString() || "";
  const password = data.get("email")?.toString() || "";

  // basic email verifications
  if (!email.includes("@domain.com")) {
    error = "Invalid email. It should contain @domain.com";
  }
}
---
<main>
  <h1>Welcome</h1>
  {error && <span>{error}</span>}
  <!-- HTML markup for the form -->
</main>
Enter fullscreen mode Exit fullscreen mode

Enhancing the form client side

The form works great but the page is flashing: when we submit the form, we make an HTTP POST request and get back an HTTP response which is basically a new, whole, document. The browser refreshes to display that new document and it creates an effect of flickering.
This is the default behaviour when using HTML form and used to work that way for decades now.

There is one thing that we can do: when JavaScript is activated and loaded on the page, instead of making a regular HTML form request, we can use the fetch function to submit the form. That way, we avoid the page refresh and create a feeling of interactivity.

---
// index.astro
// The POST handling server side
---
<main>
  <h1>Welcome</h1>
  {error && <span>{error}</span>}
  <!-- HTML markup for the form -->
</main>

<script>
  // Get the form reference
  const form = document.querySelector("form");

  if (form) {
    // listen to the submit event of the form
    form.addEventListener("submit", function (e) {
      // Avoid to page flickering and deal with the form client side
      e.preventDefault();

      // fetch the current route instead of using the HTML form submission
      fetch(form.action, {
        method: form.method,
        body: new FormData(form),
        headers: {
          accept: "application/json",
        },
      }).then((res) => res.json());
    });
  }
</script>
Enter fullscreen mode Exit fullscreen mode

At this point, if you try to submit the form, the page does not refresh anymore. If you check the network tab of your browsers devtools, you should see a POST request hitting your current route but it resolves a whole a document.

The reason is that our Astro backend only knows how to deal with regular HTML form. It doesn't know yet how to return either a whole document when the request comes from a regular HTML form or a simple JSON object when it receives a fetch request.

Let's modify the backend code so that it can deal with both types of requests:

---
let error: string | undefined;

if (Astro.request.method === "POST") {
  const data = await Astro.request.formData();
  const email = data.get("email")?.toString() || "";
  const password = data.get("email")?.toString() || "";

  if (!email.includes("@domain.com")) {
    error = "Invalid email. It should contain @domain.com";
  }

  // Verify if the request comes from the client or the server
  const isClientRequest =
    Astro.request.headers.get("accept") === "application/json";

  // If it comes from the client, return a JSON response with the data
  if (isClientRequest) {
    const response = new Response(JSON.stringify({ error }));

    return response;
  }
}
---
Enter fullscreen mode Exit fullscreen mode

If you now try to submit the form again with JavaScript enabled, with your devtools opened, you can see that the fetch request now returns a JSON object with the error. It means that we can deal with this object to update the UI.

Cleaning up the ground and abstracting

It's already a bunch of code. Let's try to abstract the code from both the client and the server so that we can use it in different places easily.

On the client

We only want the most necessary code to enhance the form submission with as less code as possible. We can, as one example, end up with something like the following:

<script>
  import { handleClientForm } from "./handleClientForm";

  handleClientForm(document.querySelector("form"));
</script>

Enter fullscreen mode Exit fullscreen mode

Let's move the content of the previous <script> tag into a dedicated handleClientForm function:

export const handleClientForm = (form?: HTMLFormElement | null) => {
  if (!form) return;

  // listen to the submit event of the form
  form.addEventListener("submit", function (e) {
    // Avoid to page flickering and deal with the form client side
    e.preventDefault();

    // fetch the current route instead of using the HTML form submission
    fetch(form.action, {
      method: form.method,
      body: new FormData(form),
      headers: {
        accept: "application/json",
      },
    }).then((res) => res.json());
  });
};
Enter fullscreen mode Exit fullscreen mode

On the server

Same goes for the server: the idea is to move things in a dedicated action function to try getting as close as possible to what Remix does:

import type { AstroGlobal } from "astro";

export type ActionReturnType<T> = Promise<{
  response?: Response;
  data?: T;
}>;

export type CallbackAction<T> = () => Promise<T>;

export const action = async <T>(
  Astro: AstroGlobal,
  actionFn: CallbackAction<T>
): ActionReturnType<T> => {
  if (Astro.request.method === "POST") {
    const isClientRequest =
      Astro.request.headers.get("accept") === "application/json";

    // We have moved the Astro.request.formData() computations
    // outside this action function. This form checking
    // should now be done in the callback
    const data: T = await actionFn({ request: Astro.request });

    if (isClientRequest) {
      const response = new Response(JSON.stringify(data));

      return { response, data };
    }

    return { data };
  }

  return { data: undefined };
};
Enter fullscreen mode Exit fullscreen mode

Here is how we would use it into the page module:

const { data, response } = await action(Astro, async () => {
  const data = await Astro.request.formData();
  const email = data.get("email")?.toString() || "";
  const password = data.get("email")?.toString() || "";

  if (!email.includes("@domain.com")) {
    return { error: "Invalid email. It should contain @domain.com" };
  }
});

if (response) return response;
Enter fullscreen mode Exit fullscreen mode

The action function returns two objects: a data one and a response one.

  • When we submit the form using the regular HTTP way, we got back an entire document that is computed by astro with the available variables: in this case, it will use data to populate the template and since data.error might be filled, the template is generated accordingly.
  • When we submit the form using JavaScript on the client, we want to get back a JSON object. We don't want Astro to generate a whole HTML document. In this case, we wrap the JSON object into a response and early return it so that Astro does not try to generate a new HTML document.

Make the client code update the UI

From the previous sections about client side code, we sent a fetch request, but the UI was not updated. The reason is because the variable used in the Astro page are only available when building the page server side. Since we have shortcut this mechanism with the JSON response, we need to update the DOM manually when JavaScript kicks in.

This is the limitation I was referring too at the beginning of this article.

Remix wraps the whole page with a React context. In React, context is known to work both client AND server side:

  • if we submit the form without JavaScript, the action function will be executed and its returned value will be passed to the React context on the server. Then Remix will render the whole React tree (on the server again) and send it back as a document with the updated content.
  • if we submit the form with JavaScript, the action function is called as a fetch endpoint. Remix has now access to the data on the client and can pass it down via context. It re-renders the React tree (on the client again) and the content is updated.

Beautiful trick.

In our case, we can't do that: we don't have context in Astro. And even if we use React for our islands, the context provider should still be in our page, written by us. Plus, because of islands, the context might not work as you may think it would.

With that in mind, we have different solutions and it's up to you to chose one depending on your context. They are less elegant than the Remix one.

I will go with a framework agnostic one, without leveraging React or anything but instead I will rely on data-* attribute to update text nodes that need to be updated:

<main>
  <h1>Welcome</h1>
  <!--  Let's add the data-form-id attribute so that our script can updated it -->
  <span data-form-id="error">{data?.error}</span>
  <form method="post">
    <label>
      Email
      <input type="email" name="email" />
    </label>

    <label>
      Password
      <input type="password" name="password" />
    </label>

    <button>Sign in</button>
  </form>
</main>
Enter fullscreen mode Exit fullscreen mode

And then adjust the handleClientForm function to update these node when we got an answer from the backend:

export const handleClientForm = (form?: HTMLFormElement | null) => {
  if (!form) return;

  form.addEventListener("submit", function (e) {
    e.preventDefault();

    fetch(form.action, {
      method: form.method,
      body: new FormData(form),
      headers: {
        accept: "application/json",
      },
    })
      .then((res) => res.json())
      .then((data) => {
        // Get all the dom node with a data-form-id attribute
        const allSlots = document.querySelectorAll("[data-form-id]");
        allSlots.forEach((slot) => {
          // For each of them get their value, which is basically the key of the data we get from the server,
          // in our case `error`
          const dataName = slot.getAttribute("data-form-id");
          const datum = data[dataName!];

          // update the according DOM node with the value received from the fetch call
          slot.textContent = datum;
        });
      });
  });
};
Enter fullscreen mode Exit fullscreen mode

With this code, the UI will update after the fetch request is made.

Of course, this blog post does not cover every single bit of form handling. It aims to be an experiment to achieve something that I find very beautiful in another framework.

If you want to play with it in Stackblitz, give it a look:

https://stackblitz.com/edit/withastro-astro-lgq9es?embed=1&file=src%2Fpages%2Findex.astro

Top comments (2)

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
isimmons profile image
Ian Simmons

I have a scenario you may have dealt with. I have a newsletter signup form in the footer so it appears on every page and then a post comments form on the single post slug page for displaying a post. So there are 2 forms in separate components but effectively on the same page as far as the url is concerned.
I need a way to differentiate between the 2 forms for the PE part because when on the post slug page, it could be either one of these forms sending a POST request.
My first thought was to use a hidden field and get it from formData() in each form component and then only process the request if it is the correct form. But this causes an error in astro. Apparently the request body can not be consumed more than once so the first component gets the formData() just fine but then the second one causes a fatal error trying to get formData() because the request body has already been consumed.
So here is what I came up with.

if (Astro.request.method === "POST") {
  if (Astro.url.searchParams.get("submit") === "post-comment") {
   // process a post comment...
  }
}

and the form action
action={`${Astro.url.href}/?submit=post-comment`}
Enter fullscreen mode Exit fullscreen mode

Then the same thing for the newsletter signup form but with ?submit=newsletter

Do you have any other ideas how to deal with this? Am I missing a way to get some form of identifier through the request object?

Thanks