Adding LiveSvelte to an Existing LiveView Project

2024-11-26

I recently needed to add a component with client-side state to a Phoenix LiveView application. This post describes the steps taken to add LiveSvelte components to a view.

Introduction

I recently wanted to add some features to a website that needs some client-side state, and I wanted to try out LiveSvelte, a library that allows you to render Svelte components inside a LiveView.

I was slightly concerned, as installing LiveSvelte requires changes to the default Phoenix asset pipeline configuration and the asset build process, but the process was smooth and I didn't face problems with the configuration.

The Feature: Animating Goals

The NHL Finns site allows Finnish NHL fans to check the scores, see how Finnish players did in last night’s games, and view the league’s overall statistics. I wanted to add two features:

  • Show the locations for each goal scored for a specific player during a season
  • Show NHL-provided video clips of every goal of the season for the player

These new features take a list of goals with properties required to display and animate over them, allowing the user to change the animation speed or move to the next goal in the list. The client-side state required was quite simple:

  • Which goal are we looking at at the moment
  • What is the configured animation speed

While this could easily be done in LiveView, the server doesn’t need to know the current index or the speed the user selected (unless there’s a goal-watching party, which might be an interesting feature).

Adding LiveSvelte to a Phoenix Project

The readme on the LiveSvelte GitHub page states that we need to make the following changes to enable LiveSvelte:

Install the Elixir dependency

LiveSvelte can be installed by adding the dependency to mix.exs and running mix deps.get:

       {:live_toast, "~> 0.6.4"},
+      {:live_svelte, "~> 0.14.1"},
       {:morphix, "~> 0.8.0"},

Update mix.exs commands for handling assets

The next change is to update the asset.deploy command in mix.exs to run the new build script (created in the next step):

       "assets.deploy": [
-        "esbuild default --minify",
         "tailwind default --minify",
+        "cmd --cd assets node build.js --deploy",
         "phx.digest"
       ]

Run mix live_svelte.setup to generate esbuild configuration

Now that we are not using the default esbuild configuration, we need to generate a new configuration. This can be done with the mix live_svelte.setup command. It generates a build.js file that configures the building of the assets for both the client and the server If you want to use server-side rendering. Since I didn’t need that, I removed the server-related configuration from the new file.

Import LiveSvelte in html_helpers

To make it easy to reference and render Svelte components, we need to import LiveSvelte to html_helpers in <app name>_web.ex.

+      import LiveSvelte
       import Phoenix.HTML

Add svelte files to tailwind configuration

Tailwind only keeps the class names used in the project in the deployed app.css file. To determine which classes are in use, the Tailwind build process checks all files that match the patterns defined in the content property of the Tailwind config file. To make sure the Svelte component classes are included, we need to add Svelte component paths to the list:

     "../lib/**/*.ex",
     "../deps/live_toast/lib/**/*.*ex",
+    "./svelte/**/*.svelte",
   ],

Configure LiveSvelte Hooks

LiveSvelte comes with some hooks that need to be added to app.js:

const svelteHooks = getHooks(Components);
Object.keys(svelteHooks).forEach((key) => {
  Hooks[key] = svelteHooks[key];
});

Dockerfile

In the Dockerfile, I needed to add nodejs and curl as they were not needed in the default Phoenix configuration:

 # Install build dependencies
-RUN apt-get update -y && apt-get install -y build-essential git npm \
+RUN apt-get update -y && apt-get install -y build-essential git npm curl \
   && apt-get clean && rm -f /var/lib/apt/lists/*_*

+# Install nodejs for build stage
+RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
+

LiveSvelte Component Example

When using LiveSvelte, the data is provided by LiveView and passed in as a property for a component.

<div id="goal-animation-container" class="tab-content">
  <.svelte name="GoalLocations" props={%{goals: @goals}} />
</div>

The rendering responsibility is then left to the LiveSvelte component. Note: I haven’t used Svelte before, so this might not be the optimal way. This code sample demonstrates how to use the data passed in as a property, handle events from the server, and update the client-side state.

<script>
  import { onMount, onDestroy } from "svelte";
  import { Radio } from "flowbite-svelte";
  import { goalStrength, shotType } from "../js/utils/goalData";

  // live contains methods to interact with the server
  // for example, handleEvent, pushEvent
  export let live;

  // Properties passed in are defined with export let, 
  // goals is being set in the LiveView code
  export let goals = [];

  // Client-side state variables are defined with let
  let currentIndex = 0;
  let animationIntervalId = null;
  let goalDisplayTime = "3000";
  let isAnimating = false;
  let isPaused = false;

  // More JS functions ...


  // Handle a tabChanged event from the server to stop animation. The LiveView UI has tabs, and I couldn't figure out how to stop the animation on the client side only when changing LiveView tabs.
  // 
  live.handleEvent("tabChanged", (reply) => {
    pauseAnimation();
    currentIndex = 0;
    goal = null;
  });

  // Things to do when mounting the component
  onMount(() => {
    rink = document.querySelector("#hockey-rink");
    marker = document.querySelector("#goal-marker");
    info = document.querySelector("#goal-info");

    const rinkRect = rink.getBoundingClientRect();
    rinkWidth = rinkRect.width;
    rinkHeight = rinkRect.height;
  });

  // Things to do when destroying the component
  onDestroy(() => {
    if (animationIntervalId) {
      clearInterval(animationIntervalId);
    }
  });

  // React to animationIntervalId changes
  $: {
    if (animationIntervalId) {
      clearInterval(animationIntervalId);
      animationIntervalId = setInterval(showNextGoal, goalDisplayTime);
    }
  }

</script>
 <% code to display goal animation on tops of the rink %>

 <%!-- Example how to set a client state value from radio buttons --%>
 
    <div class="flex flex-row gap-2" id="animation-controls">
      <Radio class="pr-3 me-1 text-xl" bind:group={goalDisplayTime} value="1000"
        >Nopea</Radio
      >
      <Radio class="p-3 me-1 text-xl" bind:group={goalDisplayTime} value="3000"
        >Perus</Radio
      >
      <Radio class="p-3 me-1 text-xl" bind:group={goalDisplayTime} value="5000"
        >Hidas</Radio
      >
    </div>

The End Result

You can check how the features turned out here:

Summary

I found LiveSvelte very nice to work with and will use it again when client-side state is needed without troubling the server. It’s very easy to integrate on a LiveView page and keeps all the LiveView benefits in how easy it is to get data to the component without building an API.