Phoenix LiveView lets you build dynamic web apps without writing much JavaScript. But sometimes, you do need a bit of client-side magic - especially for animations or local state that you don't want to store on the server.

Table of contents

    Instead of throwing LiveView entirely out of the window, you can enhance it by embedding components from your favorite frontend frameworks like Svelte, Vue, or React. Of course, you can achieve similar results using client hooks but in practice, the code often becomes hard to manage, and juggling with phx-update="ignore" can be annoying.

    That’s where libraries like LiveSvelte, LiveVue, and LiveReact come in. These libraries allow you to embed reactive frontend components directly into your LiveView templates with minimal effort. What’s even better is that by using these packages, you're not limited to building everything from scratch. You gain access to the entire ecosystem of packages available for your chosen framework.

    We’ll walk through some simple examples - using local state, passing content through slots, and even throwing in some LiveView-style sigils for good measure. The goal is to show how seamlessly these frontend components can blend into your LiveView workflows without giving up the things that make LiveView great.

    Note: For simplicity, I’m leaving out styling in the code snippets.

    How it works

    To make everything work, each integration relies on a custom vite or esbuild configuration, which replaces the default one provided by LiveView.

    When you render e.g. a Svelte component inside your template it looks something like this:

    <.svelte name="Component" socket={@socket} props={%{count: @count}}>
      <:header><h2>Header Slot</h2></:header>
      <div><p>Inner (default) slot</p></div>
    </.svelte>

    You pass props via a map, and you can also define named slots just like in normal LiveView components. What, in this case, LiveSvelte does under the hood is convert this into a plain HTML <div> with data attributes that the JS Hook can pick up on.

    That ends up looking something like this in the rendered HTML:

    <div
      id="Component-8774"
      data-name="Component"
      data-props="{'count':0}"
      data-slots="{'default':'base-64','header':'base-64'}"
      phx-update="ignore"
      phx-hook="SvelteHook"
    ></div>

    Let’s break that down:

    • data-name - the name of the component to mount
    • data-props - JSON-encoded props passed from LiveView
    • data-slots - base64-encoded HTML for each slot (used for named and default slot content)
    • phx-update="ignore" - tells LiveView not to diff or patch this node
    • phx-hook="SvelteHook" - attaches the SvelteHook hook
    • id - id of the element required by LiveView because of phx-hook and phx-ignore

    The SvelteHook is responsible for reading these attributes, decoding the slot content and rendering it to HTML, using the correct Svelte component based on name provided in data-name, and using Svelte to render it inside the div.

    How LiveSvelte knows which component to render? It's simple - the package uses the file path to determine the component name. So if your component is nested inside folders, for example assets/svelte/components/Button.svelte, you would use name="components/Button" in your LiveView. Under the hood, SvelteHook creates a look up object with all available components. If the component was already rendered on the server (via SSR), it gets hydrated. Otherwise, it’s rendered entirely on the client.

    All of these packages support Server-Side Rendering out of the box - it’s enabled by default. Just make sure node is installed on your production server, as it’s required for rendering components on the server.

    Svelte

    My personal favorite, and arguably the most natural pairing with LiveView. Svelte works seamlessly with esbuild, making it the most straightforward integration of all the packages we’ll look at today.

    Setup

    1. Add {:live_svelte, "~> 0.15.0"} to deps in mix.exs, then run mix deps.get.

    2. Run mix live_svelte.setup - this generates config and sets up the build script.

    3. Import LiveSvelte in your html_helpers/0 (lib/your_app_web.ex).

    4. Replace aliases in mix.exs for setup and assets.deploy.

       setup: ["deps.get", "ecto.setup", "cmd --cd assets npm install"],
       "assets.deploy": ["tailwind <app_name> --minify", "cmd --cd assets node build.js --deploy", "phx.digest"]
    5. If using Tailwind, add "./svelte/**/*.svelte" to tailwind.config.js under content so your styles get picked up.

    6. Remove old esbuild and tailwind configs from config.exs and deps from mix.exs - LiveSvelte uses custom esbuild config and it handles Tailwind.

    7. Let’s install a component library (optional) cd assets && npm i carbon-components-svelte

    Components

    Let’s look at a practical example using a Svelte component with LiveSvelte. We'll build a simple counter component that can increment and decrement by a derived local value, with all the goodness of Svelte and pushing events back to LiveView. All of your Svelte components should be placed in the assets/svelte directory.

    // assets/svelte/Counter.svelte
    <script>
      import { Button } from "carbon-components-svelte";
        
      let { count = 0, live } = $props();
    
      let base = $state(1);
      let multi = $state(1);
      let by = $derived(base * multi);
      
      function increment() {
        live.pushEvent("inc", { by });
      }
    </script>
    
    <p>{count}</p>
    <Button onclick={increment}>+{by}</Button>
    <Button phx-click="dec" phx-value-by={by}>-{by}</Button>
    <input type="range" min="1" max="10" bind:value={base} />
    <input type="range" min="1" max="10" bind:value={multi} />

    As you can see, the live object gives us access to LiveView interactions like pushEvent, handleEvent, and file upload. The live object is available in props only if you provide socket to the component in the heex template. You’re not limited to this approach either - traditional phx- bindings still work just fine, and you can freely mix both styles based on your needs.

    Note: When using component libraries with phx- bindings, keep in mind that components may strip away any non-standard HTML attributes. This means attributes like phx-click or phx-value-* might be ignored unless the component explicitly forwards them.

    # lib/example_web/live/test_one_live.ex
    defmodule ExampleWeb.TestOneLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <.svelte name="Counter" socket={@socket} props={%{count: @count}} />
        """
      end
        
      @impl true
      def mount(_params, _session, socket) do
        {:ok, assign(socket, count: 0)}
      end
        
      @impl true
      def handle_event("inc", %{"by" => by}, socket) do
        by = if is_binary(by), do: String.to_integer(by), else: by
        {:noreply, update(socket, :count, &(&1 + by))}
      end
    
      def handle_event("dec", %{"by" => by}, socket) do
        by = if is_binary(by), do: String.to_integer(by), else: by
        {:noreply, update(socket, :count, &(&1 - by))}
      end
    end

    To use a Svelte component, you’ll use the <.svelte /> component and provide it with a few attributes:

    • name – the name of the Svelte component (e.g. for Counter.svelte, you'd use name="Counter"),
    • socket – the LiveView socket, required if you want access to the live object inside the component,
    • props – a map of props that will be forwarded into the component via $props() ,
    • ssr - when provided with false value disables SSR.

    Slots

    Let’s take a look at how to use slots in a Svelte component rendered via LiveSvelte.

    // assets/svelte/TodoForm.svelte
    <script>
        let { children, header } = $props();
    </script>
    
    <section>
        {@render header?.()}
        <div>{@render children?.()}<div/>
    </section>

    Slots work just like in LiveView. You can define named slots (like :header) and the default inner slot (children), and render them inside your Svelte component with {@render slot()}.

    Here’s how it looks from the LiveView side:

    # lib/example_web/live/test_two_live.ex
    defmodule ExampleWeb.TestTwoLive do
        use ExampleWeb, :live_view
        
        @impl true
        def render(assigns) do
            ~H"""
            <.svelte name="TodoForm" socket={@socket}>
                <:header><h2>Create Task</h2></:header>
                <.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
                    <.input field={@form[:name]} label="Name" required />
                    <.button type="submit">Create</.button>
                </.form>
            </.svelte>
            """
        end
        # *rest of the logic*
    end

    One current limitation: you can't slot other Svelte components. Everything passed into a slot must be plain HTML or LiveView components.

    Sigil

    But what if you want to write Svelte components directly inside your LiveView module? That’s possible using the ~V sigil.

    # lib/example_web/live/test_three_live.ex
    defmodule ExampleWeb.TestThreeLive do
        use ExampleWeb, :live_view
    
        @impl true
        def render(assigns) do
            ~V"""
            <script>
                const { live } = $props();
            </script>
            
            <button onclick={() => live.pushEvent("inc", { by: 10 })}>Click</button>
            """
        end
        # *rest of the logic*
    end

    The ~V sigil compiles the inline Svelte code into a component under assets/svelte/_build. This means you must use relative imports starting with .. when referencing other Svelte components.

    // assets/svelte/_build/Elixir.ExampleWeb.TestThreeLive.svelte
    <script>
        const { live } = $props();
    </script>
        
    <button onclick={() => live.pushEvent("inc", { by: 10 })}>Click</button>

    Note: Be sure to add /assets/svelte/_build/ to your .gitignore, as these files are generated and shouldn't be committed.

    React

    React is the most widely used frontend framework. Loved by some, hated by others, but impossible to ignore.

    Setup

    The setup is slightly more complex, as it requires vite. In development, a running vite server is used to render server side rendered components on the fly. But don’t worry you don’t need to run the vite server manually (look at the 4th step 😉).

    1. Add required dependencies to mix.exs and run mix deps.get

       {:live_react, "~> 1.0.0"},
       {:nodejs, "~> 3.1.2"} # for SSR in production
    2. Run mix live_react.setup - this generates server entry point file and configs for vite, typescript and postcss.

    3. Update config/dev.exs with

       config :live_react,
         vite_host: "http://localhost:5173",
         ssr_module: LiveReact.SSR.ViteJS,
         ssr: true
    4. Update watchers in config/dev.exs

       config :example, ExampleWeb.Endpoint,
           # ...   
           watchers: [
               npm: ["run", "dev", cd: Path.expand("../assets", __DIR__)]
           ]
    5. Update live_reload in config/dev.exs - add notify section and remove live|components from patterns

       config :example, ExampleWeb.Endpoint, 
           live_reload: [
               notify: [
                   live_view: [
                       ~r"lib/example_web/core_components.ex$", 
                       ~r"lib/example_web/(live|components)/.*(ex|heex)$"
                   ]
               ],
               patterns: [
                   ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
                   ~r"lib/example_web/controllers/.*(ex|heex)$"
               ]
           ]
    6. Update config/prod.exs with

       config :live_react,
           ssr_module: LiveReact.SSR.NodeJS,
           ssr: true
    7. Import LiveReact in your html_helpers/0 (lib/yourapp_web.ex).

    8. Update /assets/js/app.js with

       // *rest of the code*
       import topbar from "topbar" // instead of ../vendor/topbar
       import { getHooks } from  "live_react";
       import components from "../react-components";
       import "../css/app.css" // the css file is handled by vite
       // *rest of the code*
       let liveSocket = new LiveSocket("/live", Socket, {
           hooks: { ...getHooks(components) }, // hooks :)
           longPollFallbackMs: 2500,
           params: { _csrf_token: csrfToken },
       });
       // *rest of the code*
    9. Update aliases in mix.exs for setup and assets.deploy.

         "assets.setup": ["cmd --cd assets npm install"],
         "assets.build": [
           "cmd --cd assets npm run build",
           "cmd --cd assets npm run build-server"
         ],
         "assets.deploy": [
           "cmd --cd assets npm run build",
           "cmd --cd assets npm run build-server",
           "phx.digest"
         ]
    10. If using Tailwind, add "./react-components/**/*.(jsx|tsx)" to tailwind.config.js under content so your styles get picked up.

    11. Remove old esbuild and tailwind configs from config.exs and deps from mix.exs - LiveReact uses custom vite config and it handles Tailwind

    12. Update root.html.heex,

      <LiveReact.Reload.vite_assets assets={["/js/app.js", "/css/app.css"]}>
        <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
        <script type="module" phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
        </script>
      </LiveReact.Reload.vite_assets>
    13. Update application.ex (required for SSR in production)

      children = [
          {NodeJS.Supervisor, [path: LiveReact.SSR.NodeJS.server_path(), pool_size: 4]},
          # rest of the code 
      ]

    Components

    We will revisit the same example as in Svelte: a simple counter component that increments and decrements based on a derived local value. This time, however, your components should be placed under the assets/react-components directory.

    // assets/react-components/Counter.jsx
    import React, { useMemo, useState } from "react";
    
    export function Counter({ count = 0, pushEvent }) {
      const [base, setBase] = useState(1);
      const [multi, setMulti] = useState(1);
    
      const by = useMemo(() => base * multi, [base, multi]);
    
      const increment = () => {
        pushEvent("inc", { by });
      };
    
      return (
        <>
          <p>{count}</p>
          <button onClick={increment}>+{by}</button>
          <button phx-click="dec" phx-value-by={by}>-{by}</button>
          <input
            type="range"
            min="1"
            max="10"
            value={base}
            onChange={(e) => setBase(e.target.valueAsNumber)}
          />
          <input
            type="range"
            min="1"
            max="10"
            value={multi}
            onChange={(e) => setMulti(e.target.valueAsNumber)}
          />
        </>
      );
    }

    In Svelte, we accessed the live object directly from the props. In React, however, all the values from the live object are spread across the props. This means you can use destructuring to extract only the specific values you need.

    // assets/react-components/index.js
    import { Counter } from "./Counter";
    
    export default { Counter };

    This time, we need to create the lookup object for components ourselves. While it adds an extra step, it gives us the flexibility to expose only the components we want and assign them the names we prefer.

    # lib/example_web/live/test_one_live.ex
    defmodule ExampleWeb.TestOneLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <.react name="Counter" socket={@socket} count={@count} />
        """
      end
      *rest of the logic*
    end

    To use a React component, you’ll use the <.react /> component and provide it with a few attributes:

    • name - the name of the component in the look up object,
    • socket - the LiveView socket, required if you want access to the live values inside the component,
    • ssr - when provided with false value disables SSR
    • props are passed directly without any wrapper, so you’ll need to assign them as attributes to the component. This can be a bit inconvenient, especially when our data includes a field named "name".

    Slots

    Unfortunately, React only supports the default inner slot.

    // assets/react-components/TodoForm.jsx
    export function TodoForm({ children }) {
      return <div>{children}</div>;
    }

    Note: Remember to add new components to the look up object

    # lib/example_web/live/test_two_live.ex
    defmodule ExampleWeb.TestTwoLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <.react name="TodoForm" socket={@socket}>
          <h2>Create Task</h2>
          <.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
            <.input field={@form[:name]} label="Name" required />
            <.button type="submit">Create</.button>
          </.form>
        </.react>
        """
      end
      # *rest of the logic*
    end

    Vue

    I’m not deeply familiar with Vue, but after a quick dive, I was pleasantly surprised. The syntax is clean and approachable, even for someone new to the framework. What really stands out, though, is the ecosystem - well-documented, mature, and full of high-quality libraries that can slot right into your project with minimal hassle.

    Setup

    The setup for LiveVue is nearly identical to LiveReact, it also relies on vite for building and serving components.

    1. Add required dependencies to mix.exs and run mix deps.get

       {:live_vue, "~> 1.0.0"},
       {:nodejs, "~> 3.1.2"} # for SSR in production
    2. Run mix live_vue.setup - this generates server entry point file and configs for vite, typescript and postcss.

    3. Update config/dev.exs with

       config :live_react,
         vite_host: "http://localhost:5173",
         ssr_module: LiveVue.SSR.ViteJS,
         ssr: true
    4. Update watchers in config/dev.exs

       config :example, ExampleWeb.Endpoint,
           # ...   
           watchers: [
               npm: ["--silent", "run", "dev", cd: Path.expand("../assets", __DIR__)]
           ]
    5. Update live_reload in config/dev.exs

       config :example, ExampleWeb.Endpoint, 
           live_reload: [
               notify: [
                   live_view: [
                       ~r"lib/example_web/core_components.ex$", 
                       ~r"lib/example_web/(live|components)/.*(ex|heex)$"
                   ]
               ],
               patterns: [
                   ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
                   ~r"lib/example_web/controllers/.*(ex|heex)$"
               ]
           ]
    6. Update config/prod.exs with

       config :live_react,
           ssr_module: LiveVue.SSR.NodeJS,
           ssr: true
    7. Use LiveVue in your html_helpers/0 (lib/yourapp_web.ex).

    8. Update /assets/js/app.js with

       // *rest of the code*
       import topbar from "topbar" // instead of ../vendor/topbar
       import { getHooks } from  "live_vue";
       import liveVueApp from "../vue";
       import "../css/app.css" // the css file is handled by vite
       // *rest of the code*
       let liveSocket = new LiveSocket("/live", Socket, {
           hooks: { ...getHooks(liveVueApp) }, // hooks :)
           longPollFallbackMs: 2500,
           params: { _csrf_token: csrfToken },
       });
       // *rest of the code*
    9. Update aliases in mix.exs for setup and assets.deploy.

         setup: ["deps.get", "assets.setup", "assets.build"],
         "assets.setup": ["cmd --cd assets npm install"],
         "assets.build": [
           "cmd --cd assets npm run build",
           "cmd --cd assets npm run build-server"
         ],
         "assets.deploy": [
           "cmd --cd assets npm run build",
           "cmd --cd assets npm run build-server",
           "phx.digest"
         ]
    10. If using Tailwind, add "./vue/**/*.vue" and ../lib/**/*.vue to tailwind.config.js under content so your styles get picked up.

    11. Remove old esbuild and tailwind configs from config.exs and deps from mix.exs - LiveVue uses custom vite config and it handles Tailwind

    12. Update root.html.heex,

      <LiveVue.Reload.vite_assets assets={["/js/app.js", "/css/app.css"]}>
        <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
        <script type="module" phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
        </script>
      </LiveVue.Reload.vite_assets>
    13. Update application (required for SSR in production)

      children = [
          {NodeJS.Supervisor, [path: LiveVue.SSR.NodeJS.server_path(), pool_size: 4]},
          # *rest of the code*
      ]

    Components

    Once again, we’ll use the same example, a simple counter with a derived local value. Place your Vue components in ./assets/vue.

    // assets/vue/Counter.vue
    <script setup lang="ts">
      import { ref, computed } from "vue";
      import { useLiveVue } from "live_vue";
    
      const live = useLiveVue();
      const props = defineProps<{ count: number }>();
      const emit = defineEmits<{ inc: [{ by: number }] }>();
    
      const base = ref<string>("1");
      const multi = ref<string>("1");
    
      const by = computed(() => parseInt(base.value) * parseInt(multi.value));
    </script>
    
    <template>
      <p>{{ props.count }}</p>
      <button @click="emit('inc', { by })">+{{ by }}</button>
      <button @click="live.pushEvent('dec', { by })">-{{ by }}</button>
      <input type="range" min="1" max="10" v-model="base" />
      <input type="range" min="1" max="10" v-model="multi" />
    </template>

    In Vue, to access the live object, you need to use the useLiveVue hook from "live_vue". This function returns the live object you can use to push events or handle uploads.

    # lib/example_web/live/test_one_live.ex
    defmodule ExampleWeb.TestOneLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <.vue v-component="Counter" v-socket={@socket} v-on:inc={JS.push("inc")} count={@count} />
        """
      end
      # *rest of logic*
    end

    To use a Vue component, you’ll use the <.vue /> component and provide it with a few attributes:

    • v-component - the name of the component,
    • v-socket - the LiveView socket, required in LiveViews,
    • v-on:event - event handler to invoke in your Vue component, has to be JS module. LiveVue under the hood uses liveSocket.execJS/2 to execute the event. These events are attached as emit handles to Vue components,
    • v-ssr - when provided with false value disables SSR
    • props are passed directly without any wrapper, so you’ll need to assign them as attributes to the component.

    Slots

    Let’s take a look at how to use slots in a Vue component rendered via LiveVue.

    // assets/vue/TodoForm.vue
    <script setup lang="ts"></script>
    
    <template>
      <section>
        <slot name="header"></slot>
        <div><slot></slot></div>
      </section>
    </template>

    In Vue, you define and render slots using the <slot /> component. On the Elixir side, slots work just like they do in regular LiveView components.

    # lib/example_web/live/test_two_live.ex
    defmodule ExampleWeb.TestTwoLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <.vue v-component="TodoForm" v-socket={@socket}>
          <:header><h2>Create Task</h2></:header>
          <.form id="task-form" for={@form} phx-change="validate" phx-submit="create">
            <.input field={@form[:name]} label="Name" required />
            <.button type="submit">Create</.button>
          </.form>
        </.vue>
        """
      end
      # *rest of the logic*
    end

    Sigil

    LiveVue, like LiveSvelte, also supports the ~V sigil for writing Vue components directly within your LiveView modules.

    # lib/example_web/live/test_three_live.ex
    defmodule ExampleWeb.TestThreeLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~V"""
        <script setup lang="ts">
          import { useLiveVue } from "live_vue";
          const live = useLiveVue();
          const props = defineProps<{ count: number }>();
        </script>
    
        <template>
          <p>{{ props.count }}</p>
          <button @click="live.pushEvent('inc', { by: 10 })">Click</button>
        </template>
        """
      end
      # *rest of the logic*
    end

    Just like in LiveSvelte, the ~V sigil compiles inline Vue code into a component under assets/vue/_build. Because of that, any imports inside must use relative paths starting with .. to reference other Vue components.

    // assets/vue/_build/Elixir.ExampleWeb.TestThreeLive.vue
    <script setup lang="ts">
      const live = useLiveVue();
    </script>
        
    <template>
      <button @click="live.pushEvent('inc', { by: 10 })">Click</button>
    </template>

    Vanilla with hooks

    While all of this is possible without using any frameworks, there's a reason we rely on them. Updating values manually with JavaScript can be cumbersome, and with LiveView, we also have to manage phx-ignore and id attributes, which adds unnecessary complexity.

    # lib/example_web/live/counter_live.ex
    defmodule ExampleWeb.CounterLive do
      use ExampleWeb, :live_view
    
      @impl true
      def render(assigns) do
        ~H"""
        <div id="counter" phx-hook="Counter">
          <.button id="inc-btn" phx-click="inc" phx-value-by="1" phx-update="ignore" class="grow">    
            +1
          </.button>
          <p>{@count}</p>
          <.button id="dec-btn" phx-click="dec" phx-value-by="1" phx-update="ignore" class="grow">
            -1
          </.button>
    
          <.input 
            id="base-input"
            name="base" 
            label="Base" 
            type="range" 
            value="1" 
            min="1" 
            max="10"
          />
          <.input
            id="multi-input"
            name="multi"
            label="Multiplier"
            type="range"
            value="1"
            min="1"
            max="10"
          />
        </div>
        """
      end
      # *rest of the logic*
     end
    // assets/js/app.js
    const hooks = {};
    hooks.Counter = {
      mounted() {
        const multiInput = this.el.querySelector("#multi-input");
        const baseInput = this.el.querySelector("#base-input");
        const incButton = this.el.querySelector("#inc-btn");
        const decButton = this.el.querySelector("#dec-btn");
    
        [multiInput, baseInput].forEach((input) => {
          input?.addEventListener("input", () => {
            const by = (multiInput?.value ?? 1) * (baseInput?.value ?? 1);
    
            incButton.textContent = `+${by}`;
            incButton.setAttribute("phx-value-by", by);
            decButton.textContent = `-${by}`;
            decButton.setAttribute("phx-value-by", by);
          });
        });
      },
    };
    // *rest of the logic*

    Streams

    While LiveView streams aren't officially supported yet, we can still use them by wrapping the component in a <div> with style="display: contents". This CSS property ensures the wrapper doesn't interfere with layout or DOM structure, it behaves as if the wrapper doesn't exist, allowing the stream to work as expected. Remember, since this approach isn’t officially supported, you might run into occasional issues.

    defmodule ExampleWeb.StreamsLive do
        use ExampleWeb, :live_view
        
        @impl true
        def render(assigns) do
            ~H"""
            <div id="tasks" phx-update="stream">
                <div
              :for={{dom_id, task} <- @streams.tasks}
            id={dom_id}
            class="contents"
          >
              <.svelte 
                  name="TaskItem"
                  socket={@socket} 
                  props={%{
                      id: task.id,
                name: task.name,
                completed: task.completed
                  }} 
                />
          </div>
            </div>
            """
        end
        
        @impl true
      def mount(_params, _session, socket) do
        {:ok,
         socket
         |> stream_configure(:tasks, dom_id: &"task-#{&1.id}")
         |> stream(:tasks, [%{id: 1, name: "Task 1", completed: true}])}
      end
    end

    Uncertainties

    Conclusion

    Phoenix LiveView is already great for building dynamic web apps without much JavaScript. But sometimes, you need more interactivity - like animations or complex local state. LiveSvelte, LiveVue, and LiveReact make it easy to embed components from popular frontend frameworks directly into your LiveView templates. The LiveView ecosystem can still feel a bit immature at times, especially when it comes to component libraries. One big advantage of using these integrations is that you instantly gain access to the most of the ecosystem of your chosen frontend framework. Of course, it’s not all sunshine and butterflies. Adding another layer to your stack brings more complexity and higher resource usage. You’ll need to manage build tools like Vite, write more JavaScript, which some developers avoid like a plague and handle two sources of translations. Still, these tools offer a powerful middle ground: the productivity and real-time benefits of LiveView, combined with the flexibility of modern frontend frameworks.

    Source (look at branches 🙂)


    Jakub Melkowski - Elixir & React Developer at Curiosum
    Jakub Melkowski Elixir & React Developer

    Read more
    on #curiosum blog