DEV Community

Cover image for E2E Reactivity using Svelte with Phoenix LiveView
Ryan Cooke
Ryan Cooke

Posted on • Originally published at jumpwire.ai

E2E Reactivity using Svelte with Phoenix LiveView

This post is based on a talk given at ElixirConf 2022

Svelte is a reactive JS framework that is delightful to use - it finds a perfect balance between simplicity and power. But since it's running in a browser, there's still the issue of fetching data from the server, and keeping that data state consistent between clients and backend servers.

In contrast, LiveView (part of Elixir's Phoenix framework) takes a contrarian view and insists that state should be managed only on the server. This makes sense since data is stored in a database close to the server, and not accessible by the browser! However there are drawbacks to the user experience, as events need to make a round trip from the browser to the server and back whenever a user changes state.

If only there is a way to combine the snappy UX of client side rendering in Svelte, with the strong-consistency state management of LiveView. Well dear reader, what if I told you...

Do you know what reactive is

But first, why Svelte?

Few JavaScript frameworks I've used have captured "simplicity" in the way that Svelte is designed. In a gross generalization, code runs in the browser primarily to store state, capture events and manipulate DOM. Sometimes this is executing simple logic based on user interaction with the page, such as form entry validation. In other cases, there is complex interplay between elements displayed on the webpage. Often some part of the markup needs to be re-generated and rendered, as a result of changes.

A gross generalization: code runs in the browser primarily to store state, capture events and manipulate DOM

A lot of modern frameworks create significant abstractions on top of this basic utility, such as "lifecycles" for rendering, or finite state machines to manage data. They also like to reinvent markup, as though HTML/CSS/DOM are not sufficient for modern browsers on their own (cough JSX cough).

Svelte uses vanilla HTML/CSS/JS and adds just enough extra syntax to render JS variables in HTML, conditionally render blocks based on variables' state, loop and iterate over data, etc. Components logically organize and scope HTML/CSS/JS into a single file. This means you can grab some CSS from a css-tricks post and use it, without concerns about conflicting with styles outside of the component.

What is slick about Svelte is that it doesn't need a "virtual DOM" to determine which markup to re-render, but instead precompiles the page into JavaScript functions that may be called by the Svelte engine to generate updated markup, which is injected to existing DOM. This approach is not all that different than jQuery code you've probably written which manipulates DOM directly through selectors, and keeps rendering very snappy and efficient for the browser.

Svelte gives developers precise control over which JS variables should be treated as reactive, and this can include logical blocks in addition to plain variables, which means components can react to derived state.

On top of the reactive features, Svelte adds a lot of gravy - context/stores for state management across components, tweening and transitions for buttery smooth UX animations to give that native-app feeling, and actions for interfacing third-party libraries into the reactive system.

Svelte is gravy

Everything everywhere all the time

All of this reactivity to local state changes is great, except for a glaring omission - a lot of state changes happen on the server, not just in the browser! Keeping state on the client up-to-date with a server creates a lot of challenges around consistency, especially if the client can update state as well. Heck, entire areas of academia are devoted to algorithms that try to keep state consistent across distributed nodes. And since the protocol of HTTP requires separate requests/responses for exchanging information, most solutions for keeping the browser up-to-date involve polling the server to refetch state.

This feels pretty clumsy compared to the smart reactivity of our client-side framework, which just figures out that state has changed and updates itself.

Enter Phoenix LiveView - a framework for server-rendered HTML that takes a similar reactive approach to re-rendering where LiveView does the hard work of tracking changes and sending the relevant diffs to the browser. This makes the server the single source of truth, regardless if state changes on the client or the server.

At the end of the day, a LiveView is nothing more than a process that receives events as messages and updates its state. The state itself is nothing more than functional and immutable Elixir data structures. The events are either internal application messages or sent by the client/browser.

While this solves hairy problems around data consistency, rendering everything on the server has a detrimental impact on UX, as browser events must be handled by the server as well. Here's an example of how to show modal with animation in LiveView, which IMO is a ridiculous amount of code. While JavaScript code can still execute in the browser, they are triggered through "hooks" supplied by LiveVew. In practice this makes it hard to design UX elements that leverage browser capabilities heavily, and using third-party libraries requires retrofitting into a LiveView hook.

But underneath the hood, something magical is happening - LiveView is using authenticated WebSockets to send diffs of HTML from the server to the browser for rendering.

When powers combine

What if we could take the best of both of these frameworks - using Svelte to drive a snappy browser UX while leveraging LiveView to keep Svelte property state up-to-date with changes from the server?

In this post, I describe exactly how to do that! Not only will we wire LiveView's lifecycle interface into Svelte's component lifecycle to update state, but we will also inject LiveView's WebSocket into Svelte components to make it easy to send updates from the client back to the server without needing an XMLHttpRequest.

This will be fun, let's get started.

NOTE: the full code for this post can be found here

I bootstrapped a new Phoenix app named "swiphly" using mix (note that LiveView is enabled by default in newer versions of Phoenix):

mix phx.new . --app swiphly --database sqlite3
Enter fullscreen mode Exit fullscreen mode

Because Svelte is precompiled, we need a way to include it into the javascript package build pipeline of Phoenix. The latest version of Phoenix leverages esbuild, but a few modifications are necessary to use esbuild plugins. You can find the full instructions in the Phoenix documentation, but the commands I used are:

npm install esbuild svelte esbuild-svelte --save-dev
npm install ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view --save
Enter fullscreen mode Exit fullscreen mode

I also need to add a custom ./assets/build.js file since we are overriding the built-in config. This will include an esbuild plugin to compile Svelte components into our package:

const esbuild = require('esbuild')
const sveltePlugin = require('esbuild-svelte')

const args = process.argv.slice(2)
const watch = args.includes('--watch')
const deploy = args.includes('--deploy')

const loader = {
  // Add loaders for images/fonts/etc, e.g. { '.svg': 'file' }
}

const plugins = [
  // Add and configure plugins here
  sveltePlugin(),
  // ... other plugins such as postCss, etc.
]

let opts = {
  entryPoints: ['js/app.js'],
  mainFields: ["svelte", "browser", "module", "main"],
  bundle: true,
  minify: false,
  target: 'es2017',
  outdir: '../priv/static/assets',
  logLevel: 'info',
  loader,
  plugins
}

if (watch) {
  opts = {
    ...opts,
    watch,
    sourcemap: 'inline'
  }
}

if (deploy) {
  opts = {
    ...opts,
    minify: true
  }
}

const promise = esbuild.build(opts)

if (watch) {
  promise.then(_result => {
    process.stdin.on('close', () => {
      process.exit(0)
    })

    process.stdin.resume()
  })
}
Enter fullscreen mode Exit fullscreen mode

And that's about it, now Phoenix will precompile all ./assets/**/*.svelte files, and include them in the static JavaScript bundles under ./priv/static/assets. So far so easy!

Two lifecycles become one

Now for the fun part. We need a way to instantiate Svelte components as part of LiveView's rendering engine, and we'll do this with a Svelte-specific LiveView component with a custom hook for creating and updating a Svelte JavaScript object.

First, the LiveView component

We define a LiveView component in Phoenix, which creates a div with two data attributes:

  • data-name which will contain the name of the Svelte component to use for rendering, and
  • data-props to hold variable state that should be passed as properties to the Svelte component.

It also wires a phx-hook (Phoenix hook) to the element, which will map LiveView lifecycle methods into Svelte component lifecycle methods. We'll look at that shortly.

Data properties will be encoded as JSON when rendered by Phoenix, and parsed as JSON in the browser.

defmodule SwiphlyWeb.SvelteComponent do
  use SwiphlyWeb, :live_component

  def render(assigns) do
    ~H"""
    <div id={@id || @name} data-name={@name} data-props={json(@props)} phx-update="ignore" phx-hook="SvelteComponent"></div>
    """
  end

  defp json(nil), do: ""
  defp json(props) do
    # encode props as JSON, using Jason or other
  end
end
Enter fullscreen mode Exit fullscreen mode

We can use this LiveView component when rendering from a controller action:

defmodule SwiphlyWeb.EndToEnd do
  use SwiphlyWeb, :live_view

  @impl true
  def mount(params, session, socket) do
    # do some server side logic
    # add state to the socket assignments
    socket.assign(:foo, "bar")
    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~H"""
      <.live_component module={SwiphlyWeb.SvelteComponent} id="foo" name="MySvelteComponent" props={%{foo: @foo}} />
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

This renders the component defined above by referencing the module SwiphlyWeb.SvelteComponent, and passes server state from the socket assignments into a props variable of the module. There is a bit of indirection happening between the controller and the component, but it scopes only the state to the component that it needs to render.

So far, we have an HTML div that gets rendered by Phoenix as a LiveView component, with state encoded as a data attribute. The magic of the LiveView component is that it is aware of server-side state changes - whenever the props variable changes, LiveView detects this and will call a method in our custom hook in the browser.

Joining two frameworks

Let's take a look at the custom hook now, we define this in ./assets/js/hooks.js. Recall that we specified this hook in the LiveView component div with a phx-hook attribute above.

There's a lot of code here, so we'll start at the top. Svelte components compile into JavaScript objects, we can import them directly.

We specify which Svelte component to render from the LiveView component above by setting data-name to the variable name of the import. Note that the variable name of the import must match the data-name attribute of the LiveView component div. Yes this is souper hacky, but due to limitations in esbuild, a dynamic require isn't possible isn't possible 🤦.

The interface for a custom LiveView hook is the link between LiveView and Svelte lifecycles - we are instantiating the Svelte component during mounted(), updating its props during updated(), and cleaning up during destroyed().

Svelte objects are pretty simple as well, providing a constructor that takes a target DOM element and a list of properties. The DOM element I am using is simply the div being rendered by the LiveView component. I can reference an instance of this object through this._instance when updating properties or removing the component. And recall that LiveView will call these hook methods whenever state changes on the server socket assignments!

import MySvelteComponent from "./components/MySvelteComponent.svelte"

const components = {
  MySvelteComponent,
}

function parsedProps(el) {
    const props = el.getAttribute('data-props')
    return props ? JSON.parse(props) : {}
}

const SvelteComponent = {
    mounted() {
        // 
        const componentName = this.el.getAttribute('data-name')
        if (!componentName) {
            throw new Error('Component name must be provided')
        }

        const requiredApp = components[componentName]
        if (!requiredApp) {
            throw new Error(`Unable to find ${componentName} component. Did you forget to import it into hooks.js?`)
        }

        const request = (event, data, callback) => {
            this.pushEvent(event, data, callback)
        }

        const goto = (href) => {
            liveSocket.pushHistoryPatch(href, "push", this.el)
        }

        this._instance = new requiredApp({
            target: this.el,
            props: {...parsedProps(this.el), request, goto },
        })
    },

    updated() {
        const request = (event, data, callback) => {
            this.pushEvent(event, data, callback)
        }

        const goto = (href) => {
            liveSocket.pushHistoryPatch(href, "push", this.el)
        }

        this._instance.$$set({...parsedProps(this.el), request, goto })
    },

    destroyed() {
        this._instance?.$destroy()
    }
}

export default {
    SvelteComponent,
}
Enter fullscreen mode Exit fullscreen mode

In updated() we are calling into Svelte internals, using $$set to update properties without re-instantiating the entire object. Similarly with $destroy, we call this internal Svelte function to release component resources and remove from DOM.

Bonus round

Now that we have end-to-end reactivity from the backend to the client, this is like a dream come true! But wait, there's more...

I'm also wrapping two methods from LiveView and adding them to the Svelte component props - pushEvent and pushHistoryPatch. pushEvent lets us send data to the server on the WebSocket, without needing XMLHttpRequest/fetch/axios, and is authenticated and encrypted. pushHistoryPatch helps with navigating to other pages, and skips full page reloads if not necessary.

Let's see how this looks in MySvelteComponent

Here's a simple component containing a form and button, with requests to the server passing a name, data and callback() parameter. That's all you need, no paths or parameters, methods or headers.

<script>
    export let foo, request
    let form
    let formResult = ''

    function createFoo() {
        const data = new FormData(form)
        request('create', Object.fromEntries(data.entries()), (result) => {
            if (result?.success) {
                setTimeout(() => { form.reset() }, 555)
                formResult = 'Foo created'
            } else if (result?.reason) {
                formResult = `Error creating foo: ${result.reason}`
            } else {
                formResult = 'An unknown error has occurred'
            }
        })
    }

    function deleteFoo() {
        request('delete', { foo_id: 123 }, () => {})
    }
</script>

<form
    class="grid grid-cols-2 gap-4 form-control"
    bind:this={form}
    on:submit|preventDefault={createFoo}
>
    <!-- some form fields -->
    <div class="font-semibold text-accent">{formResult}</div>
</form>
<div class="button" on:click={deleteFoo}>
Enter fullscreen mode Exit fullscreen mode

Pattern matching FTW

And on the server, Elixir can pattern match on the name and properties to decompose logic:

defmodule SwiphlyWeb.PushEvent do
  # same code as above...

  @impl true
  def handle_event("create", %{"foo" => "bar"}, %{assigns: %{}} = socket) do
    with {_, _} <- Foo.create_bar(params) do
      # stuff...
    end
  end

  @impl true
  def handle_event("create", %{"foo" => "!bar"}, %{assigns: %{}} = socket) do
    with {_, _} <- Foo.create_notbar(params) do
      # stuff...
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

What's amazing about this setup is that I don't need to optimistically update local state, since the server will push the updated property state back down the hook! In practice, this cuts out half of the JS code I would normally have in my Svelte component.

One more thing

Let's review what we have so far:

  • Created a "cybernetic webapp" (Svelte's own words) with reactive components for a snappy browser experience
  • Hooked Svelte component properties to server-side state managed by Phoenix LiveView components for live re-rendering based on changes from the server
  • Leveraged the LiveView WebSocket to easily send changes from the client to the server as events, without messing with AJAX/REST

Pretty nice, huh!

But what if state is changing from another browser client, and that update hits a different server than I'm connected to? This is a pretty common scenario at production scale, as I'll have many servers handling browser requests. Think of any collaboration app with many users interacting with the same content.

Phoenix has another trick up its sleeve - a flexible pubsub system that is easily clustered across servers. By adding a few extra lines of code to publish an event when state is updated, all servers are notified and can update the state they are holding for their browser clients.

End-to-end reactivity can include n:n browsers and servers, not just 1:1

End-to-end graphic

This is truly incredible! And because Phoenix uses a separate process for each connected browser client, this approach scales beautifully as well.

Happy coding!

- JumpWire team

Top comments (13)

Collapse
 
woutdp profile image
Wout De Puysseleir

Love this. I tried it out and it works!

I was wondering if it would be possible to Server Side Render the Svelte component, it only loads in in the browser in my setup so you get a brief flash of unrendered content. Is that something you looked into by any chance?

It would be awesome to have a full integration working where you could simply provide a Svelte sigil just like how surface-ui does it but for Svelte... That seems like the ultimate E2E reactivity experience, your post makes me think it's totally possible...

Collapse
 
debussyman profile image
Ryan Cooke

I don't think it's possible to Server Side Render the component, since the Svelte "engine" is running client-side as JavaScript and is manipulating the DOM in the browser.

Do address the flash, I'd use the LiveView provided hooks for showing/hiding the "loading" state. The default is a top-of-the-page loading bar, but you can override it to show any UI you'd like, and this HTML should be rendered server side. This would work if your main view component is a LiveView, and it has Svelte components embedded.

A sigil should be possible! My example uses LiveComponent, but any element that can trigger the custom hook should work, and since that's just an element attribute, I don't see a reason that couldn't be coded as a sigil. I'll have to give it a try!

Collapse
 
woutdp profile image
Wout De Puysseleir

Hey Ryan, I've been working on a package that incoorporates this blogpost

github.com/woutdp/live_svelte

It's very early stages, there's still a bunch of things to do, but it's a working prototype!

It does have Server Side Rendering for Svelte! I was able to make it work by invoking Node from within the live component.

I intend to work on it more and flesh it out before releasing it to a more wide audience, and I also want to make a video to showcase some of the features.

Would love to have you involved in any way possible if you feel up for it and have the interest!

Thread Thread
 
debussyman profile image
Ryan Cooke

This is really cool! I hadn't yet seen LiveComponents using NodeJS.call! like you are, didn't know that was possible.

I'd love to help out! I see you already sketched out some planned issues on the repo. What's the best way to get involved?

Thread Thread
 
woutdp profile image
Wout De Puysseleir

Hi Ryan so happy to hear back from you! Any way you can help is definitely appreciated! You can comment on any issue and I can let you know if I already looked into it and what my findings were

Also if you think the API can be improved definitely up for refactoring a bit, might not be ideal yet.

For issues:

  • I'm looking into slots now, seeing if it's possible to pass a slot from the LiveComponent to Svelte, that would be really cool for things like Modals. But still struggling with it to make it work. In SSR you can pass raw html as a slot, but in the client you need to interact with $$slots which doesn't work for me at the moment

  • Another thing I was struggling with was goto which didn't work for me, I saw that just writing it like this works for me: github.com/woutdp/live_svelte#live...

Thread Thread
 
debussyman profile image
Ryan Cooke

Sounds good, I'll pull the project and play around with it! And if I can make some headway on slots, I can add some comments or open a PR.

I'm also really curious about the Svelte component when using Node on the server-side, I might try some profiling to see how that looks on the client.

Exciting stuff!

Collapse
 
ktec profile image
globalkeith

Fun! Thanks for the write up!

Collapse
 
debussyman profile image
Ryan Cooke

Thanks for reading!

Collapse
 
pookiepats profile image
pookiepats

I'm a SQL / DBA guy by trade - my company was just purchased and now in order to stay on I am now magically SQL/ DBA & Web Dev guy by trade : D

I have two weeks to get a SPA LIVE and I'm going to use your method, i'll let you know how it goes! Thank you! ( i think haha)

Collapse
 
debussyman profile image
Ryan Cooke

So how'd it go? Really curious if it was easy to pick up :)

Collapse
 
clsource profile image
Camilo

I like Svelte and love phoenix. This can be a nice way to combine both.
But how this approach is better than using Inertia.js and Svelte with a Phoenix Backend?.

Thanks :)

Collapse
 
debussyman profile image
Ryan Cooke • Edited

The critical difference is the data exchange.

With Inertia, you still need to fetch data from the client (while also managing authentication), using some JS/HTTP library (fetch, axios). Basically your backend is a typical API or SSR, fetching data by polling or through user interaction or page reload.

With Svelte/LiveView, you get an authenticated web socket which lets you invoke actions on the backend by pushing messages from JS. This web socket is managed by LiveView, so you don't have to worry about setup/teardown/reconnect.

But the killer feature is that LiveView can push data changes from the server to the client over the socket, without the client requesting it, and Svelte will rerender the component automatically! Again, LiveView manages this state for you using the socket assigns. In real-world apps, not all data updates originate from a single user in their browser, so this creates a truly seamless app-like experience.

Collapse
 
nalaka profile image
Nalaka Jayasena

Mind blown!