DEV Community

Cover image for Go Beyond the Basics: Mastering Toast Notifications with Go and HTMX
Michael Murphy
Michael Murphy

Posted on

Go Beyond the Basics: Mastering Toast Notifications with Go and HTMX

HTMX is pretty amazing. I probably don't have to sell it to you, you are here after all. HTMX is great at allowing us to directly manipulate the DOM with HTML returned directly from the server, but what do we do when there is an error in the backend? When a resource the user is looking for doesn't exist or the user submits invalid data into a form? It is easy enough to return an HTML partial to a specific part of the form to indicate there was an issue, but what if we want a robust solution that works in any part of the application no matter what the user is doing?

For this, we can take advantage of HTMX Response Headers to trigger events on the front end that raise toast notifications to present to the user.

What will we be building?

In this example, we are going to walk through building a newsletter sign-up component. If the sign-up is successful, the component should be replaced with a thank you message, but if there is some error, such as when the form is not completed or Tom is trying to sign up (Tom is a genius, we all know he doesn't need my newsletter), a toast notification should be presented and the form should not be replaced.

The code for this example will be available on my GitHub profile.

Check out an example of this working on YouTube

What HTMX response headers will we be using?

The HX-Reswap response header tells HTMX to change the value of the hx-swap attribute before swapping the HTML. In our example, we will be changing this to none when there is an error, indicating we do not want any swapping to take place.

HX-Reswap: none
Enter fullscreen mode Exit fullscreen mode

The HX-Trigger response header allows us to specify an event that should be triggered on the front end. The value of the header can be a simple event name or, as is our case, a JSON representation of the event and details:

HX-Trigger: {"eventName": {"message": "this is a message"}}
Enter fullscreen mode Exit fullscreen mode

What technology will we be using?

We will obviously be using HTMX and Go etc. but we will also be using:

JavaScript - I know, I know, you're using HTMX so you don't have to do any JS, but it's only a little bit and it does complement this solution very well. The aim of HTMX is not to make JavaScript redundant but to use it where it is the tool for the job. In my opinion, this is one of those moments.

html/template - we will be using the standard HTML templating library built into Go. It is a great library and perfect for simple things like this, though if you have a more complicated project (I assume you do), I would look into using something like templ.

Echo - we will be using the Echo framework for routing. Echo is great as it returns errors from its handlers and so allows for a simple approach for centralizing error handling. If you are using some other framework, great, this approach should be easily adaptable.

Let's get started!

Create a new project (you know how to do that) and install the Echo framework:

go get github.com/labstack/echo/v4
Enter fullscreen mode Exit fullscreen mode

Following is the structure of the example app we are creating:

.
├── handler
│   └── handler.go
├── static
│   ├── app.css
│   └── toast.js
├── toast
│   └── toast.go
└── view
│   ├── index.html
│   └── success_partial.html
├── go.mod
├── go.sum
├── main.go
Enter fullscreen mode Exit fullscreen mode

Let's get the app to do some basic rendering first:

The HTML:

Following is the index.html file, which includes a basic form for the newsletter subscription and a section#toast-container element that will be responsible for holding the toast notifications. Note that I will not be including the CSS in this demonstration, if you want the CSS, please view the repo on my GitHub.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Home Page</title>
    <link rel="stylesheet" href="app.css">
    <script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
    <script src="toast.js" defer></script>
</head>
<body>
    <main>
        <section class="section">
            <h1>Subscribe to me!</h1>
            <p>Welcome to the amazing newsletter! Subscribe to learn things.</p>
            <p>Ensure your email address is correct and don't bother subscribing if your name is Tom!</p>
        </section>

        <form hx-post="/newsletter" hx-swap="outerHTML">
            <div>
                <label for="name">Name</label>
                <input type="text" id="name" name="name" placeholder="What should we call you?">
            </div>
            <div>
                <label for="email">Email</label>
                <input type="text" id="email" name="email" placeholder="Enter your email address">
            </div>
            <button type="submit">Subscribe</button>
        </form>
    </main>
    <section id="toast-container"></section>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

In the above example, you can see the form uses the hx-post attribute to indicate the route for subscribing, and the hx-swap attribute to indicate the entire form should be replaced. We don't need the hx-target attribute as it will default to the form. This is describing the happy path of replacing the form with a thank you message when the form is submitted.

Note that I am using an input type of text for the email to allow invalid emails to be submitted for demonstration purposes, we should and would use an email input in the wild.

Following is the success_partial.html file that will replace the form when the user has successfully subscribed. This will present the user's name in place of the {{ . }}, followed by a thank-you message:

<section class="success">
    <p><strong>{{.}}</strong>, thank you for signing up to my amazing newsletter.</p>
    <p>Get ready for some spam!</p>
</section>
Enter fullscreen mode Exit fullscreen mode

Handlers

Now let's make the basic handlers for serving the index page and handling the newsletter signup request:

package handler

type HomeHandler struct{}

func NewHomeHandler() HomeHandler {
    return HomeHandler{}
}

func (h HomeHandler) HandleIndexPage(c echo.Context) error {
    return c.Render(http.StatusOK, "index.html", nil)
}

func (h HomeHandler) HandleNewsletterSignUp(c echo.Context) error {
    name := strings.TrimSpace(c.FormValue("name"))
    email := strings.TrimSpace(c.FormValue("email"))

    if err := validateName(name); err != nil {
        return err
    }

    if err := validateEmailAddress(email); err != nil {
        return err
    }

    return c.Render(http.StatusOK, "success_partial.html", name)
}
Enter fullscreen mode Exit fullscreen mode

Nothing complicated here. In HandleIndexPage we are simply returning the index.html template and in HandleNewsletterSignUp we are validating the name and email, returning the error if there is one, otherwise rendering the success_partial.html template.

Note how we can simply return errors from our handlers, we will be harnessing this later to present our errors as toast notifications.

Following are the example validation functions, note that these are terrible on purpose and for demonstration purposes only:

// validateName returns an error if the name is not valid or if the name is Tom.
// This is a terrible implementation.
func validateName(name string) error {
    if name == "" {
        return errors.New("How are we supposed to spam you if we don't know your name?")
    }

    if strings.ToLower(name) == "tom" {
        return errors.New("Tom is a genius, there is no need to subscribe!")
    }

    return nil
}

// validateEmailAddress returns an error if the email is not valid.
// This is a terrible implementation.
func validateEmailAddress(email string) error {
    if email == "" {
        return errors.New("An email address must be provided")
    }

    if !strings.Contains(email, "@") {
        return errors.New("the email address must include an @ symbol")
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

We will make some small changes to the validation and the HandleNewsletterSignUp handler later, but this will do for now.

The main.go file:

type Template struct {
    templates *template.Template
}

func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
    return t.templates.ExecuteTemplate(w, name, data)
}

func main() {
    // Precompile templates
    t := &Template{
        template.Must(template.ParseGlob("view/*.html")),
    }

    // Init echo
    e := echo.New()
    e.Renderer = t
    e.Static("/", "static")

    h := handler.NewHomeHandler()

    // Set up handlers
    e.GET("/", h.HandleIndexPage)
    e.POST("/newsletter", h.HandleNewsletterSignUp)

    // Start the server
    e.Logger.Fatal(e.Start(":5000"))
}
Enter fullscreen mode Exit fullscreen mode

If you are following along, you will now have a server that works. You will be able to submit the form and receive the success_partial.html partial. If you pass invalid information, the component will not be swapped but you will automatically get a status 500 internal server error because we are not handling the errors returned by the server.

Let's make some toast

The following code is from the toast.go file and will help us instantiate toast notification errors. There isn't too much code, have a read and we will go over it afterward. Note that we will be going over this in the order it makes sense to write it, rather than line by line:

package toast

import (
    "encoding/json"
    "fmt"
    "github.com/labstack/echo/v4"
)

const (
    INFO    = "info"
    SUCCESS = "success"
    WARNING = "warning"
    DANGER  = "danger"
)

type Toast struct {
    Level   string `json:"level"`
    Message string `json:"message"`
}

func New(level string, message string) Toast {
    return Toast{level, message}
}

func Info(message string) Toast {
    return New(INFO, message)
}

func Success(c echo.Context, message string) {
    New(SUCCESS, message).SetHXTriggerHeader(c)
}

func Warning(message string) Toast {
    return New(WARNING, message)
}

func Danger(message string) Toast {
    return New(DANGER, message)
}

func (t Toast) Error() string {
    return fmt.Sprintf("%s: %s", t.Level, t.Message)
}

func (t Toast) jsonify() (string, error) {
    t.Message = t.Error()
    eventMap := map[string]Toast{}
    eventMap["makeToast"] = t
    jsonData, err := json.Marshal(eventMap)
    if err != nil {
        return "", err
    }

    return string(jsonData), nil
}

func (t Toast) SetHXTriggerHeader(c echo.Context) {
    jsonData, _ := t.jsonify()
    c.Response().Header().Set("HX-Trigger", jsonData)
}
Enter fullscreen mode Exit fullscreen mode

Firstly, we create some constants for the toast notification levels, these could be anything you want and you could be a bit fancier here if you want. We then create a struct to represent a toast and create a constructor function for it:

const (
    INFO    = "info"
    SUCCESS = "success"
    WARNING = "warning"
    DANGER  = "danger"
)

type Toast struct {
    Level   string `json:"level"`
    Message string `json:"message"`
}

func New(level string, message string) Toast {
    return Toast{level, message}
}
Enter fullscreen mode Exit fullscreen mode

Then we create an Error method on the Toast struct so it satisfies the error interface. This will allow us to return a Toast in any function that returns an error:

func (t Toast) Error() string {
    return fmt.Sprintf("%s: %s", t.Level, t.Message)
}
Enter fullscreen mode Exit fullscreen mode

The meat of the toast sandwich; we create a SetHXTriggertHeader method, that sets the HX-Trigger header on the Echo context. We also have a private jsonify helper method which marshalls the Toast struct into the JSON format expected by HTMX:

func (t Toast) jsonify() (string, error) {
    // Set the message to it's error representation to include the level
    t.Message = t.Error()

    // Create the map expected by HTMX
    eventMap := map[string]Toast{}
    eventMap["makeToast"] = t

    // Convert the structure to JSON
    jsonData, err := json.Marshal(eventMap)
    if err != nil {
       return "", err
    }

    return string(jsonData), nil
}

func (t Toast) SetHXTriggerHeader(c echo.Context) {
    jsonData, _ := t.jsonify()
    c.Response().Header().Set("HX-Trigger", jsonData)
}
Enter fullscreen mode Exit fullscreen mode

Note that here we are ignoring errors from the Marshal method. This should be fine as we know the data will always be valid, though you may wish to implement better error handling here.

The value of the header is created using the jsonify method, which essentially creates a JSON representation of an event named makeToast with detail describing the toast notification. Later we will be using some JavaScript to read this and present the toast notification.

{ 
    "makeToast": { 
        "level": "warning", 
        "message": "this is a warning" 
    } 
}
Enter fullscreen mode Exit fullscreen mode

We then create some helper functions for creating the different toast levels, this way we can call toast.Warning("message") rather than toast.New(toast.WARNING, "message"):

func Info(message string) Toast {
    return New(INFO, message)
}

func Success(c echo.Context, message string) {
    New(SUCCESS, message).SetHXTriggerHeader(c)
}

func Warning(message string) Toast {
    return New(WARNING, message)
}

func Danger(message string) Toast {
    return New(DANGER, message)
}
Enter fullscreen mode Exit fullscreen mode

Note that the Success function is the only one that takes the Echo context and does not return a Toast struct. This is because we will be using it differently and will become obvious later.

Return Toast in place of errors

Now that we have implemented and fleshed out the toast package, we can use it to create and return toast notifications instead of plain old errors. Head back to the handlers we created before and within the validation functions, return a toast instead of using the errors.New method. Your validation methods should now look something like this:

// validateName returns an error if the name is not valid or if the name is Tom.
// This is a terrible implementation.
func validateName(name string) error {
    if name == "" {
       return toast.Warning("How are we supposed to spam you if we don't know your name?")
    }

    if strings.ToLower(name) == "tom" {
       return toast.Danger("Tom is a genius, there is no need to subscribe!")
    }

    return nil
}

// validateEmailAddress returns an error if the email is not valid.
// This is a terrible implementation.
func validateEmailAddress(email string) error {
    if email == "" {
       return toast.Warning("An email address must be provided")
    }

    if !strings.Contains(email, "@") {
       return toast.Warning("the email address must include an @ symbol")
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Note that we have not changed the return type of these functions as the Toast struct satisfies the error interface due to the implementation of the Error method on the Toast struct, allowing us to return it as an error.

At this point we are still not setting the HX-Trigger header with the event that will eventually trigger the toast notification, let's do that now. In your main.go file, write the following function:

func customErrorHandler(err error, c echo.Context) {
    // Attempt casting the error as a Toast.
    te, ok := err.(toast.Toast)

    // If it canot be cast as a Toast, it must be some other error
    // we did not handle. We will handle it here and return a more
    // generic error message. We don't want system errors to bleed
    // through to the user.
    if !ok {
       fmt.Println(err)
       te = toast.Danger("there has been an unexpected error")
    }

    // If not a success error (weird right) set the HX-Swap header
    // to `none`.
    if te.Level != toast.SUCCESS {
       c.Response().Header().Set("HX-Reswap", "none")
    }

    // Set the HX-Trigger header
    te.SetHXTriggerHeader(c)
}
Enter fullscreen mode Exit fullscreen mode

This function is essentially error-handling middleware that Echo will use to handle errors returned from the handlers. This is simple, but has some important steps:

  1. Attempt to cast the error to a Toast struct. The ok variable will indicate if this is successful or not.

  2. Handle instances where this is not successful. If the error cannot be cast as a Toast, it is likely just an error. Here we are constructing a new generic toast notification using toast.Danger as we wouldn't want any generic system errors to be presented to the user.

  3. We then set the HX-Reswap header to the value of none, preventing HTMX from swapping whatever was returned to the front end, which, in the event of an error, is likely nil. Note that we don't do this when the toast is a success type as we will still want the normal action to continue in the event of a success toast notification.

  4. Finally, we set the HX-Trigger to include the event using the SetHXTriggerHeader method we created earlier.

Once we have created the custom error handler function, we need to tell the Echo framework we want to use it to handle errors returned from our handlers. Add the following line of code to the main.go file somewhere after the line e := echo.New():

e.HTTPErrorHandler = customErrorHandler
Enter fullscreen mode Exit fullscreen mode

Test what we have so far

If we run the application, enter some erroneous values into the form, and submit it; we should be able to see some results in the response headers:

Here we can see that both the HX-Reswap and HX-Trigger headers have been set and the latter includes the JSON of the makeToast event we defined earlier.

We should notice that the form is not swapped out, but there will be no notifications yet as we haven't implemented the JavaScript to react to the HTMX event caused by the HX-Trigger header. Let's do that now.

The JavaScript

Create a toast.js file and place it in the static folder being served by the server. We are going to create a class representing a Toast notification. If you are one of those people who have a strange aversion to classes in JavaScript, feel free to make this into set functions, but I like containing all of the logic in one place.

Firstly, what is this JavaScript trying to achieve? We essentially want to create an HTML element representing a toast notification and inject it into the toast container on the page. Following is an example of the HTML we are trying to create:

<button class="toast toast-danger" role="alert" aria-label="Close">
    <span>danger: Tom is a genius, there is no need to subscribe!</span>
</button>
Enter fullscreen mode Exit fullscreen mode

A button has been used here as clicking on the toast notification should remove it from the DOM.

Following is the basis of the Toast class, we will be adding methods to this as we go:

class Toast {
    /**
     * A class representing a Toast notification.
     * @param level {("info"|"success"|"warning"|"danger")}
     * @param message { string }
     */
    constructor(level, message) {
        this.level = level;
        this.message = message;
    }
}
Enter fullscreen mode Exit fullscreen mode

The following is a private (indicated by the #) method responsible for creating the outer button element.

/**
 * Makes the toast container element. A button containing the entire notification.
 * @returns {HTMLButtonElement}
 */
#makeToastContainerButton() {
    const button = document.createElement("button");
    button.classList.add("toast");
    button.classList.add(`toast-${this.level}`);
    button.setAttribute("role", "alert");
    button.setAttribute("aria-label", "Close");
    button.addEventListener("click", () => button.remove());
    return button;
}
Enter fullscreen mode Exit fullscreen mode
  1. We dynamically add classes depending on the toast level, which allows us to style each one differently.

  2. We set some attributes on the button for accessibility and usability.

  3. We then add an event listener to the button to remove itself from the DOM when clicked.

Next, add a private method that adds the toast message to the button:

/**
 * Makes the element containing the body of the toast notification.
 * @returns {HTMLSpanElement}
 */
#makeToastContentElement() {
    const messageContainer = document.createElement("span");
    messageContainer.textContent = this.message;
    return messageContainer;
}
Enter fullscreen mode Exit fullscreen mode

Not much to this one, add a span and add some text to it. Next, we create the show method that will present the toast notification to the user:

/**
 * Presents the toast notification at the end of the given container.
 * @param containerQuerySelector {string} a CSS query selector identifying the container for all toasts.
 */
show(containerQuerySelector = "#toast-container") {
    const toast = this.#makeToastContainerButton();
    const toastContent = this.#makeToastContentElement()
    toast.appendChild(toastContent);

    const toastContainer = document.querySelector(containerQuerySelector);
    toastContainer.appendChild(toast);
}
Enter fullscreen mode Exit fullscreen mode

This method essentially calls the two private methods we created to construct the button and adds it to the #toast-container element within the index.html page. The show method uses a default parameter for the query selector to allow this to be overridden, but this isn't strictly required so could be hard coded.

That is it for the Toast class. All we need to do now is create an event listener to listen for the event we have created and create the toast notification. First, let's create a function that will create and add the toast:

/**
 * Presents a toast notification when the `makeToast` event is triggered
 * @param e {{detail: {level: string, message: string}}}
 */
function onMakeToast(e) {
    console.log(e);
    const toast = new Toast(e.detail.level, e.detail.message);
    toast.show();
}
Enter fullscreen mode Exit fullscreen mode

Nothing much to see here, we just create a Toast object and then call the show method on it. Following is the event listener, we can put this in the toast.js file too:

document.body.addEventListener("makeToast", onMakeToast);
Enter fullscreen mode Exit fullscreen mode

The name of the event is makeToast as this is what we defined as the name of the event within the toast.go file. We could have named this event whatever we wanted, but this works nicely. The event listener will call the function we just created to create the toast notification and present it to the user.

What about success notifications?

If we test the application now, all going well, we should be presented with toast notifications when there is an error returned from the HandleNewsletterSignUp handler. However, in many cases, we may want a success notification to be presented to indicate to the user that something has worked.

Go back to the HandleNewsletterSignUp handler and, right before the final return, add the following line:

toast.Success(c, "Successfully signed up to the newsletter!")
Enter fullscreen mode Exit fullscreen mode

Rather than letting the customErrorHandler handle success toast notifications, we are adding these directly to our handlers where it makes sense. This is the reason the toast.Success function takes an echo.Context parameter, in contrast to the other functions responsible for creating a Toast.

Now if we submit the form with valid values, we will see that, in addition to the form being swapped for the thank you message, we also get a success toast notification presented.

Conclusion

In conclusion, integrating toast notifications with Go and HTMX provides a powerful solution for enhancing user experience and handling application feedback seamlessly. By leveraging HTMX response headers and Go's error-handling capabilities, we've demonstrated how to present informative toast notifications to users in response to various actions and outcomes within our application.

Through this tutorial, we've explored the step-by-step process of setting up the environment, implementing toast notifications, and handling errors and success notifications effectively.

By embracing the synergy between Go's robust backend capabilities and HTMX's dynamic frontend interactions, developers can create modern and responsive web applications that deliver real-time feedback to users, enhancing their overall experience. I hope this tutorial has provided valuable insights and practical techniques for implementing toast notifications in your Go projects.

Keep experimenting, refining, and innovating with these technologies to create exceptional user experiences and drive engagement in your applications.

Top comments (0)