elixir

Build Interactive Phoenix LiveView UIs with Components

Sophie DeBenedetto

Sophie DeBenedetto on

Build Interactive Phoenix LiveView UIs with Components

LiveView empowers developers to build interactive, single-page web apps with ease by providing a framework that eliminates the need for guesswork.

In this post, we'll take a look at how you can layer simple, single-purpose functional components to wrap up shared presentation logic. We'll also use more sophisticated live components to craft easy-to-maintain single-page flows that handle complex user interactions.

Along the way, you'll gain a solid understanding of working with HEEx — Phoenix and LiveView's new templating engine — and you'll see some of LiveView's out-of-the-box function components in action.

Let's dive in!

The Feature: Compose a User Survey UI for a Phoenix LiveView App

Before we dive into writing any actual code, let's talk about the feature we'll build. Imagine that you're responsible for a Phoenix web app, Arcade, that provides in-browser games to users. A user can log in, select a game to play, and even invite friends to play games with them.

In this post, we'll build out a "user survey" feature that asks the user to fill out some demographic info about themselves and then provide a rating for each of several games. We'll focus on that second part of the survey — the game rating forms.

Let's lay out what we'll build in a bit more detail. We'll begin with a parent live view that lives at the /survey route, ArcadeWeb.SurveyLive. This live view will render a child functional component, "ratings index". The ratings index component will iterate over the games and show a game rating if one by the current user exists, or a form for a new rating if not. If users haven't completed a game rating, they will see a list of forms to rate each game 1-5 stars. If they have completed some (or all) ratings, they will see those displayed and forms to submit ratings for the games they have not yet rated.

Here's a look at how it will work:

ratings form partially complete

With our plan in place, we're ready to start writing code.

Define the Parent Live View

We'll build a route first, then mount and render the initial live view.

Define the Survey Route

Our first job is to establish a route. The survey will live at /survey, and it should only work for authenticated users so we can deliver a survey to single, identifiable users. We'll tie the route to the yet-to-be-written SurveyLive live view, with the :index live action, like this:

1# router.ex
2scope "/", ArcadeWeb do
3  pipe_through [:browser, :require_authenticated_user]
4    live "/survey", SurveyLive, :index
5end

This code assumes you've used the Phoenix Auth generator to add authentication to your Phoenix app. The details of authentication aren't important for our purposes here. Just know that the survey route is a protected route that requires an authenticated user. This means that when a logged-in user points their browser at /survey, the SurveyLive view will mount with a session argument that contains a key of "user_token" pointing to a token we can use to identify the current user. The generator also gives us a function, Accounts.get_user_by_session_token(user_token), that we will use to fetch the user for that token.

With our route established, it's time to define the SurveyLive live view.

Mount the Survey Live View

The mount/3 function builds the initial state for SurveyLive. Let's think a bit about that initial state. We need to use the current user to build our survey's demographic and rating portions since a demographic belongs to a user and a rating belongs to a game and a user. So we want to store that user in the live view's state. This way, we can make it available to the rating form component later. Now, let's implement a mount/3 function that adds the current user to socket assigns, like this:

1# lib/arcade_web/live/survey_live.ex
2defmodule ArcadeWeb.SurveyLive do
3  use ArcadeWeb, :live_view
4
5  def mount(_params, %{"user_token" => user_token}, socket) do
6    {:ok,
7      socket
8      |> assign(
9          :current_user,
10          Accounts.get_user_by_session_token(user_token))}
11  end
12end

Okay, we're ready to render a simple version of our survey live view.

Render the Survey Live View

We won't provide a render/1 function, instead we'll use a template — lib/arcade_web/live/survey_live.html.heex. Let's keep it simple for now:

1<section class="row">
2  <h2>Survey</h2>
3</section>

Reload your browser, and you'll see the bare-bones template shown here:

simple survey template

We have the basic framework for our survey UI in place. Now, we're ready to build our first function component.

List Ratings in Phoenix LiveView

Before we build our ratings index function component, let's talk about what function components are and how they work. A function component takes in an assigns argument and returns a HEEx template. Function components are implemented in modules that use the Phoenix.Component behaviour, which also gives us a convenient syntax for rendering function components.

We're almost ready to define our function component. Let's take a step back and discuss HEEx.

Understanding HEEx Templates

A HEEx template is any file ending in the .heex extension that is implicitly rendered by a live view, or any markup rendered by a live view or component that is encapsulated in the ~H""", """ tags. The HEEx templating engine is an extension of EEx. Just like EEx templates, HEEx will process template replacements within your HTML code. Everything between the <%= and %> expressions is a template replacement. HEEx will evaluate the Elixir code within those tags and replace them with the result.

HEEx does more than just templating, though. It also:

  • provides compile-time HTML validations
  • gives us a convenient component rendering syntax
  • optimizes the amount of content sent over the wire, allowing LiveView to render only those portions of the template that need updating when state changes

HEEx is the default templating engine for Phoenix and LiveView. Any generated template files in your Phoenix app will be HEEx templates and end in the .html.heex extension. When using inline render/1 functions in your live views, or function components, you'll return HEEx templates with the ~H sigil.

With that basic understanding in place, we're ready to implement the ratings index function component.

Define the Function Component

We'll build a rating index component responsible for orchestrating the state of all the game ratings in our survey. This component will iterate over the games and render the rating details if a user rating exists, or the rating form if it doesn't. The responsibility for rendering rating details will be handled by a "rating show" function component. A live "rating form" component will handle rendering and managing a rating form. More on live components in a bit.

Meanwhile, SurveyLive will continue to be responsible for managing the overall state and appearance of the survey page. The rating index component will receive the list of game ratings to render from the parent live view. The parent live view is responsible for maintaining and updating that list.

In this way, we keep our code organized and easy to maintain because it adheres to the single responsibility principle — each component has one job to do. By layering these components within the parent SurveyLive view, we compose a series of small, manageable pieces into one interactive feature — the user survey page.

We'll begin by implementing the RatingLive.Index function component. Then, we'll move on to the rating show component, followed by the rating form component.

Create a file, lib/arcade_web/live/rating_live/index.ex, and key in the following component definition:

1defmodule ArcadeWeb.RatingLive.Index do
2  use Phoenix.Component
3  use Phoenix.HTML
4  alias ArcadeWeb.RatingLive
5end

Our function component uses the Phoenix.Component behaviour which we need to render HEEx templates. Any module that implements only function components will use this behaviour. You'll use a different behaviour when building live or stateful components, which we'll do later on in this post.

We're also using the Phoenix.HTML behaviour here to bring in the Phoenix.HTML.raw/1 function that we'll use to render unicode characters — more on that in a bit. Finally, we're aliasing the name of the component module itself so it's easy to ergonomically invoke other function components defined within the module from within our main function component here.

The entry point of our module will be the games/1 function. We'll call on this function component from the parent live view to render the list of games. The function will take in an assigns argument containing the list of games passed in from the parent SurveyLive view. It will return a HEEx template that iterates over that list and renders another function component to show the game rating details if a rating exists and the rating form live component if not. Define that function now, as shown here:

1def games(assigns) do
2  ~H"""
3  <div class="survey-component-container">
4    <.heading games={@games} />
5    <.list games={@games} current_user={@current_user}/>
6  </div>
7  """
8end

We're composing our games/1 function out of two additional function components — heading/1 and list/1. We call on those function components with the .function_name assigns... /> syntax. This invokes the function component and passes in whatever assigns we provide as the assigns argument to that function.

It's also worth noting the {} interpolation syntax here, instead of the <%= %> EEx tags you might be used to. This is because HEEx, unlike EEx, isn't just responsible for evaluating and templating Elixir expressions into your HTML. It also parses and validates the HTML itself. So you can't use the traditional EEx tags inside HTML tags in a HEEx template. Instead, use curly braces to interpolate values inside HTML tags and function component calls, and use EEx tags when interpolating values in the body, or inner content, of those tags.

Let's build those additional function components now, starting with heading/1:

1def heading(assigns) do
2  ~H"""
3    <h2>
4      Ratings
5      <%= if ratings_complete?(@games), do: raw "&#x2713;" %>
6    </h2>
7  """
8end

The heading/1 function is pretty small and single-purpose. It renders an <h2> element that encapsulates some text along with a helper function that checks to see if all of the games have a rating by the current user. If so, we render the unicode to a checkmark and the user can see that all of the ratings forms have been completed.

Before we implement this helper function, let's see how we're going to render the index component with a list of games.

When we render this index component from the SurveyLive template, we'll use the SurveyLive view to query for the list of games with ratings by the current preloaded user.

Then, we'll pass that list of games down into the index component. So we can assume that each game in the @games list has its ratings list populated only with a rating from the current user. With that in mind, we can implement the ratings_complete?/1 function to iterate over the list of games and return true if there is a rating for every game. Add in your function now, like this:

1 defp ratings_complete?(games) do
2  Enum.all?(games, fn game ->
3    length(game.ratings) == 1
4  end)
5end

Now if a user has completed all of the game ratings, they'll see the "Ratings" header with a nice checkmark next to it:

ratings complete header

With the heading/1 function component out of the way, let's turn our attention to list/1. Add in this function now:

1def list(assigns) do
2  ~H"""
3  <%= for {game, index} <- Enum.with_index(@games) do %>
4    <%= if rating = List.first(game.ratings) do %>
5      <h3>Show rating coming soon!</h3>
6    <% else %>
7      <h3>Rating form coming soon!</h3>
8    <% end %>
9  <% end %>
10  """
11end

Here, we use a for comprehension that maps over all of the games in the system, where each game's ratings list contains the single preloaded rating by the given user if one exists. Inside that comprehension, the template will render the rating details if a rating exists or a form for that rating if not. Nesting components in this manner lets the reader of the code deal with a tiny bit of complexity at a time.

We'll dig into this logic a bit more when we're ready to implement these final two components. With the index component out of the way, we are ready to weave it into our SurveyLive template.

Render the Component

The next bit of code we'll write shows how the presentation of our view can change based on the contents of the socket. The SurveyLive view will use the state of the overall survey to control what is shown to the user. This view holds the list of games, and their ratings by the current user, in state. It will pass this list into the RatingLive.games/1 function component as part of the component assigns. The contents of this list will allow the games/1 function component to determine if it should show rating details or a rating form.

Let's update SurveyLive now to query for the list of games and their ratings from the current user and add them to socket assigns, like this:

1defmodule ArcadeWeb.SurveyLive do
2  use ArcadeWeb, :live_view
3  alias Arcade.Survey
4
5  def mount(_params, %{"user_token" => token}, socket) do
6    {:ok,
7     socket
8     |> assign(:current_user, Accounts.get_user_by_session_token(token))
9     |> assign(:games, Catalog.list_games_with_user_rating(user))}
10  end
11end

Assume that the Catalog.list_games_with_user_rating/1 context function returns the list of all games, with only the rating by the given user preloaded, if any.

Now we're ready to render our rating index function component from the SurveyLive template:

1<!-- lib/arcade_web/live/survey_live.html.heex -->
2  <RatingLive.Index.games games={@games}
3      current_user={@current_user} />

Here, we're once again using the <.function_component assigns... /> syntax to render our function component and the {} interpolation syntax for interpolating within tags in a HEEx template.

Now that we're rendering our RatingLive.Index.games/1 function component with the game list, let's build the stateless function component to show the existing rating for a game.

Show a Rating

We're getting closer to the goal of showing ratings, step by step. Remember, we'll show the existing ratings, and forms for ratings, otherwise.

Let's cover the case for ratings that exist first. We'll define a stateless component to show a rating. Then, we'll render that component from within the HEEx template returned by RatingLive.Index.games/1. Let's get started.

Build the Function Component

Create a file, lib/arcade_web/live/rating_live/show_component.ex, and key this in:

1defmodule ArcadeWeb.RatingLive.Show do
2  use Phoenix.Component
3  use Phoenix.HTML
4end

We're defining a module that uses the Phoenix.Component behaviour and the Phoenix.HTML behaviour, since we'll once again need support for the Phoenix.HTML.raw/1 function to render unicode characters.

Okay, let's move on to the entry point of our function component, the stars/1 function. We'll call this function from within the HEEx template returned by RatingLive.Index.games/1 with an assigns that includes the given game's rating by the current user.

The stars/1 function will operate on this rating and use some helper functions to construct a list of filled and unfilled unicode star characters. We'll construct that list using a simple pipeline, and then render it in a HEEx template, like this:

1def stars(assigns) do
2  stars =
3    filled_stars(assigns.rating.stars)
4    |> Enum.concat(unfilled_stars(assigns.rating.stars))
5    |> Enum.join(" ")
6
7  ~H"""
8  <div>
9    <h4>
10      <%= @game.name %>:<br/>
11      <%= raw stars %>
12    </h4>
13  </div>
14  """
15end

The filled_stars/1 and unfilled_stars/1 helper functions are interesting. Take a look at them here:

1def filled_stars(stars) do
2  List.duplicate("&#x2605;", stars)
3end
4
5def unfilled_stars(stars) do
6  List.duplicate("&#x2606;", 5 - stars)
7end

Examining our pipeline in the stars/1 function, we can see that we call on filled_stars/1 to produce a list of filled-in, or "checked", star unicode characters corresponding to the number of stars the game rating has. Then, we pipe that into a call to Enum.concat/2 with a second argument of the output from unfilled_stars/1. This second helper function produces a list of empty, or not checked, star characters for the remaining number of stars.

For example, if the number of stars in the rating is 3, our pipeline of helper functions will create a list of three checked stars and two un-checked stars. Our pipeline concatenates the two lists together and joins them into a string of HTML that we can render in the template.

We have everything we need to display a completed rating, so it's time to roll several components up together.

Render the Component

We're ready to implement the next phase of our plan. The RatingLive.Index.games/1 function component iterates over the list of games in the @games assigns. If a rating is present, we show it. Add in the call to our new Show.stars/1 component now, like this:

1def list(assigns) do
2  ~H"""
3  <%= for {game, index} <- Enum.with_index(@games) do %>
4    <%= if rating = List.first(game.ratings) do %>
5      <Show.stars rating={rating} game={game} />
6    <% else %>
7      <h3>Rating form coming soon!</h3>
8    <% end %>
9  <% end %>
10  """
11end

It's a straight for comprehension with an if statement. If a rating exists, we render the function component by calling on it with the game and rating assigns. If not, we need to render the form. Let's build that form and render it now.

Submit a Rating

Our rating form will display the form and manage its state, validating and saving the rating. We'll need to pass a game and user for our database relationships, and the game's index in the parent LiveView's socket.assigns.games list. We'll use this index later on to update SurveyLive state efficiently.

Build the Rating Form Component

The component will be stateful since it needs to manage the state of the rating form and respond to user interactions to validate form changes and handle the form submission.

A stateful, or live, component is any module that uses the :live_component behaviour and renders a HEEx template. Such modules can implement the live component lifecycle functions, including mount/3, update/2, and render/1, and respond to user events by implementing a handle_event/3 function. Let's define our live component module now. Create a file, lib/arcade_web/live/rating_live/form.ex, and key this in:

1defmodule ArcadeWeb.RatingLive.FormComponent do
2  use ArcadeWeb, :live_component
3  alias Arcade.Survey
4  alias Arcade.Survey.Rating
5end

This is simple enough, to begin with. We define our module, use the :live_component behaviour, and add in some aliases that we'll need later.

We'll use LiveView's .form/1 function (more on that in a bit) to construct the rating form. This function requires a changeset, so we'll need to store one in our component's state. Here's where the component lifecycle comes into play. When we render a live component, LiveView starts the component in the parent view's process and calls these callbacks, in order:

  • mount/1 : The single argument is the socket, and we use this callback to set the initial state. This callback is invoked only once, when the component is first rendered from the parent live view.

  • update/2 : The two arguments are the assigns argument given to live_component/3 and the socket. By default, it merges the assigns argument into the socket.assigns established in mount/1. We'll use this callback to add additional content to the socket each time live_component/3 is called.

  • render/1 : The one argument is socket.assigns. It works like a render in any other live view.

Stateful components will always follow this process when first mounted and rendered. Then, when the component updates in response to changes in the parent live view, only the update/2 and render/1 callbacks fire. Since these updates skip the mount/1 callback, the update/2 function is the safest place to establish the component's initial state. Let's create our component's update/2 function now, like this:

1def update(assigns, socket) do
2  {:ok,
3    socket
4    |> assign(assigns)
5    |> assign_rating()
6    |> assign_changeset()}
7end
8
9def assign_rating(
10    %{assigns: %{current_user: user, game: game}} = socket) do
11  assign(socket, :rating, %Rating{user_id: user.id, game_id: game.id})
12end
13
14def assign_changeset(%{assigns: %{rating: rating}} = socket) do
15  assign(socket, :changeset, Survey.change_rating(rating))
16end

These reducer functions will add the necessary keys to our socket.assigns. They'll drop in any assigns our parent sends, add a new Rating struct, and finally establish a changeset for the new rating.

There are no surprises here. One reducer builds a new rating, and the other uses the Survey context to build a changeset for that rating. Now, on to render.

With our socket established, we're ready to render. We'll choose a template to keep our markup code neatly compartmentalized. Create a file, lib/arcade_web/live/rating_live/form.html.heex. Add the game title markup followed by the game rating form shown here:

1<div class="survey-component-container">
2  <section class="row">
3  <h4><%= @game.name %></h4>
4  </section>
5  <section class="row">
6    <.form
7      let={f}
8      for={@changeset}
9      phx-change="validate"
10      phx-submit="save"
11      phx_target={@myself}
12      id={@id}>
13
14      <%= label f, :stars%>
15      <%= select f, :stars, Enum.reverse(1..5) %>
16      <%= error_tag f, :stars %>
17
18      <%= hidden_input f, :user_id%>
19      <%= hidden_input f, :game_id%>
20
21      <%= submit "Save", phx_disable_with: "Saving..." %>
22    </.form>
23  </section>
24</div>

The form template is really just a standard Phoenix form, although the syntax for rendering the form may be new to you. The main function in the template is the form/1 function: a function component made available by LiveView under the hood. The form function component returns a rendered HEEx template containing an HTML form built with the help of Phoenix.HTML.Form.form_for/4.

If you're feeling adventurous, you can check out the source code for the form function component. For now, all you really need to know is that calling form/1 returns an HTML form for the specified changeset, with the specified LiveView bindings. Let's take a closer look at how our form is rendered.

Since the form/1 function is built on top of the form_for/4 function, it presents a similar API. Here, we're generating a form for the @changeset assignment that was put in assigns via the update/2 callback.

Then, we bind two events to the form, a phx-change to send a validate event and a phx-submit to send a save event. We target our form component to receive events by setting phx-target to @myself, and we tack on an id.

Note that we've set a dynamic HTML id of the stateful component id, stored in socket assigns as @id. This is because the game rating form will appear multiple times on the page, once for each game, and we need to ensure that each form gets a unique id. You'll see how we set the id assigns for the component when we render it in a bit.

Our form has a stars field with a label and error tag and a hidden field for each user and game relationship. We tie things up with a submit button.

We'll come back to the events a bit later. For now, let's fold our work into the RatingLive.Index.list/1 function component.

Render the Component

The RatingLive.Index.games/1 function component should render the rating form component if no rating for the given game and user exists. Let's do that now.

1def list(assigns) do
2  ~H"""
3  <%= for {game, index} <- Enum.with_index(@games) do %>
4    <%= if rating = List.first(game.ratings) do %>
5      <Show.stars rating={rating} game={game} />
6    <% else %>
7      <.live_component module={RatingLive.Form}
8                        id={"rating-form-#{game.id}"}
9                        game={game}
10                        game_index={index}
11                        current_user={@current_user } />
12    <% end %>
13  <% end %>
14  """
15end

Here, we call on the component with the live_component/1 function, passing the user and game into the component as assigns, along with the game's index in the @games assignment. We add an :id, a requirement of all stateful components. Since we'll only have one rating per component, our id with an embedded game.id should be unique.

The live_component/1 function is a function component made available to us by the LiveView framework. It takes in an argument of some assigns and returns a HEEx template that renders the given component within the parent live view. When using live_component/1 to render a live component, you must specify an assigns of module, pointing to the name of the live component module to mount and render, and an assigns of id, which LiveView will use to keep track of the component. Also, note the {} interpolation syntax we're using — this syntax is required when interpolating within HTML or HEEx tags.

It's been a while since we've looked at things in the browser — but now, if you point your browser at /survey, you should see something like this:

ratings forms

Handle Component Events

We've bound events to save and validate our form, so we should teach our component how to do both. We need one handle_event/2 function head for each of the save and validate events. Let's start with validate:

1def handle_event("validate", %{"rating" => rating_params}, socket) do
2  {:noreply, validate_rating(socket, rating_params)}
3end

We need to build the reducer next:

1def validate_rating(socket, rating_params) do
2  changeset =
3    socket.assigns.rating
4    |> Survey.change_rating(rating_params)
5    |> Map.put(:action, :validate)
6
7  assign(socket, :changeset, changeset)
8end

Our validate_rating/2 reducer function validates the changeset and returns a new socket with the validated changeset (containing any errors) in socket assigns. This will cause the component to re-render the template with the updated changeset, allowing the error_tag helpers in our form_for form to render any errors.

Next up, we'll implement a handle_event/2 function that matches the save event:

1def handle_event("save", %{"rating" => rating_params}, socket) do
2  {:noreply, save_rating(socket, rating_params)}
3end

And here's the reducer:

1def save_rating(
2         %{assigns: %{product_index: product_index, product: product}} = socket,
3         rating_params
4       ) do
5  case Survey.create_rating(rating_params) do
6    {:ok, rating} ->
7      product = %{product | ratings: [rating]}
8      send(self(), {:created_rating, product, product_index})
9      socket
10
11    {:error, %Ecto.Changeset{} = changeset} ->
12      assign(socket, changeset: changeset)
13  end
14end

Here, we attempt to save the form. On failure, we assign a new changeset. On success, we send a message to the parent live view to do the heavy lifting for us. Then, as all handlers must do, we return the socket.

Update the Rating Index

What should happen when the game rating successfully saves? The RatingLive.Index.games/1 function should no longer render the form for that game. Instead, the survey should display the saved rating. This kind of state change is squarely the responsibility of SurveyLive. Our message will serve to notify the parent live view to change.

Here's the interesting bit. All the parent needs to do is update the socket. The RatingLive.Index.games/1 function already renders the right thing based on the content of the assigns that it receives from the parent, SurveyLive. All we need to do is implement a handler to deal with the "created rating" message.

1# lib/arcade_web/live/survey_live.ex
2def handle_info({:created_rating, updated_product, product_index}, socket) do
3  {:noreply, handle_rating_created(socket, updated_product, product_index)}
4end

We use handle_info so that the parent live view process, SurveyLive, can respond to the message sent by the child component. Now, our reducer can take the appropriate action. Notice that the message we match has a message name, an updated game, and its index in the :games list. We can use that information to update the game list without going back to the database. We'll implement the reducer below to do this work:

1def handle_rating_created(
2         %{assigns: %{products: products}} = socket,
3         updated_product,
4         product_index
5       ) do
6
7  socket
8  |> put_flash(:info, "Rating submitted successfully")
9  |> assign(
10    :products,
11    List.replace_at(products, product_index, updated_product)
12  )
13end

The handle_rating_created/3 reducer adds a flash message and updates the game list with its rating. This causes the template to re-render, passing this updated game list to RatingLive.Index.games/1. That function component, in turn, knows just what to do with a game containing a rating by the given user — it will render that rating's details instead of a rating form.

Notice the lovely layering. In the parent live view layer, all we need to do is manage the list of games and ratings. All of the form handling and rating or demographic details go elsewhere.

The end result of a submitted rating is an updated game list and a flash message. Submit a rating, and see what happens:

rating form success

You just witnessed the power of components and LiveView.

Wrap Up: You've Built a Complex LiveView UI with Components

This post covered a lot of ground. You implemented a sophisticated UI composed from simple layers, all thanks to LiveView components. You can wrap up simple markup with function components, while live components allow you to maintain component state and respond to events. Meanwhile, HEEx templates and LiveView provide some nice semantics for rendering markup and both types of components.

LiveView is growing fast — it's responding to the community's needs and providing even more ergonomic solutions for developing complex interactive single-page apps. Function components and HEEx are just some of the latest features that make LiveView even more enjoyable to use.

Happy coding!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Share this article

RSS
Sophie DeBenedetto

Sophie DeBenedetto

Our guest author Sophie is a Senior Engineer at GitHub, co-author of Programming Phoenix LiveView, and co-host of the BeamRad.io podcast. She has a passion for coding education. Historically, she is a cat person but will admit to owning a dog.

All articles by Sophie DeBenedetto

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps