Live React Islands

React-powered interactive islands inside Phoenix LiveView. Harness the NPM ecosystem with server-driven state, real-time streams and zero-lag forms + SSR.

Banner

Why Live React Islands?

The best of both worlds!

Phoenix LiveView is excellent for server-driven UIs, but sometimes you need the rich interactivity of React for specific components. Live React Islands lets you:

Comparison

Choose Live React Islands when you need rich, interactive React components without giving up LiveView’s server-driven simplicity.

Feature LiveView Only LiveView + Alpine Live React Islands (this) Pure SPA (Next.js/Vite)
UI Ecosystem Limited (HEEX/Custom) Small (Alpine kits) Infinite (NPM/React)Infinite (NPM)
Interactivity Server-Roundtrip (JS hooks for edge cases) Simple Client-side High-Fidelity / Fluid High-Fidelity / Fluid
State Management Single (Server) Fragmented Single (Server-Led) Dual (API + Client)
Initial Load / SEO Instant Instant Instant (SSR-enabled) Slow / Complex SSR
JS Bundle Size ~0kb (Core only) Small (+15kb) Large (React: ~100–150 kB gzipped) Large
Developer Speed Very High High (until complex) High (Asset Reuse) Low (API Plumbing)
Component Logic Elixir Only Mixed (Strings) JSX (Encapsulated) JSX
Complexity Ceiling Struggles with app-like complexity Hits wall on “State” High Very High

When NOT to Use Live React Islands

Installation

Elixir

Add to your mix.exs:

def deps do
  [
    {:live_react_islands, "~> 0.1.0"},
    # For development SSR (optional):
    {:live_react_islands_ssr_vite, "~> 0.1.0", only: :dev},
    # For production SSR (optional):
    {:live_react_islands_ssr_deno, "~> 0.1.0", only: :prod}
  ]
end

JavaScript

npm install @live-react-islands/core
# For development SSR (optional):
npm install --save-dev @live-react-islands/vite-plugin-ssr

Quick Start

1. Create a React Component

// src/islands/Counter.jsx
const Counter = ({ count, title, pushEvent }) => {
  return (
    <div>
      <h2>{title}</h2>
      <p>Count: {count}</p>
      <button onClick={() => pushEvent("increment", {})}>+1</button>
    </div>
  );
};

export default Counter;

2. Set Up the LiveView Hooks

// src/islands/index.js
export default { Counter: () => import("./Counter") };

Islands can be lazy loaded to only load the JS used on the page.

// src/main.jsx
import { createHooks } from "@live-react-islands/core";
import islands from "./islands";

const islandHooks = createHooks({ islands });

// Add to your LiveSocket
let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...islandHooks },
});

3. Create an Elixir Component

defmodule MyAppWeb.Components.CounterIsland do
  use LiveReactIslands.Component,
    component: "Counter",
    props: %{count: 0, title: "My Counter"}

  def handle_event("increment", _params, socket) do
    new_count = socket.assigns.count + 1
    {:noreply, update_prop(socket, :count, new_count)}
  end
end

4. Use in Your LiveView

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view
  use LiveReactIslands.LiveView

  def render(assigns) do
    ~H"""
    <.live_component module={MyAppWeb.Components.CounterIsland} id="counter-1" />
    """
  end
end

How It Works

React components receive these props automatically:

Prop Description
id The island’s unique identifier
pushEvent Function to send events to Elixir
All defined props Current values from Elixir
All consumed globals Current global state values

Features

Props

Define props with default values. Props can be set from the template or updated from event handlers:

use LiveReactIslands.Component,
  component: "Counter",
  props: %{count: 0, title: "Default Title"}

Elixir components can override init/2 for dynamic initialization:

def init(assigns, socket) do
  # Called once on mount, before SSR and first render
  socket
  |> update_prop(:computed, compute_value(assigns))
end

Updating props from Elixir:

def handle_event("increment", _, socket) do
  {:noreply, update_prop(socket, :count, socket.assigns.count + 1)}
end

Passing props from templates:

<.live_component module={CounterIsland} id="counter-1" title="Custom Title" />

Once a prop is set from outside the component any update_prop call on it will raise an error to prevent a nasty set of bugs. To just initialize the component use init_[prop] to set the value once and then the component takes over.

<.live_component module={CounterIsland} id="counter-1" init_count={5} />

Events

Send events from React to Elixir using pushEvent:

// React
<button onClick={() => pushEvent("save", { data: formData })}>Save</button>
# Elixir
def handle_event("save", %{"data" => data}, socket) do
  # Handle the event
  {:noreply, socket}
end

Global State

Share state across multiple islands. When a global changes, all islands that use it automatically rerender.

Set up in your LiveView:

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view
  use LiveReactIslands.LiveView, expose_globals: [:user, :theme]

  def mount(_params, session, socket) do
    {:ok, assign(socket, user: get_user(session), theme: "light")}
  end
end

Consume in your island:

use LiveReactIslands.Component,
  component: "Header",
  props: %{},
  globals: [:user, :theme]

Optional globals (won’t error if not set):

globals: [:user?]  # The ? suffix makes it optional

The globals are passed as props to your React component:

const Header = ({ user, theme }) => (
  <header className={theme}>Welcome, {user.name}</header>
);

Forms with Server Validation

Build forms with React UI and Elixir/Ecto validation. Input is collected client side with zero typing latency and send to Elixir for validation. Errors from the changeset get pushed back to React.

The useForm hook implements a “Validation Lock” pattern: Updates are versioned and isValid will only be true until the server confirms the current form state is valid.

Elixir component:

defmodule MyAppWeb.Components.ContactFormIsland do
  use LiveReactIslands.Component,
    component: "ContactForm",
    props: %{form: %{}}

  alias MyApp.Contact

  def init(_assigns, socket) do
    changeset = Contact.changeset(%Contact{}, %{})
    socket |> init_form(:form, changeset)
  end

  def handle_form(:validate, :form, attrs, socket) do
    changeset = Contact.changeset(%Contact{}, attrs)
    {:noreply, update_form(socket, :form, changeset)}
  end

  def handle_form(:submit, :form, attrs, socket) do
    case Contact.create(attrs) do
      {:ok, _contact} ->
        {:noreply, init_form(socket, :form, Contact.changeset(%Contact{}, %{}))}
      {:error, changeset} ->
        {:noreply, update_form(socket, :form, changeset)}
    end
  end
end

React component:

import { useForm } from "@live-react-islands/core";

const ContactForm = ({ form, pushEvent }) => {
  const {
    getFieldProps,
    getError,
    isRequired,
    isTouched,
    handleSubmit,
    isValid,
  } = useForm(form, pushEvent);

  return (
    <form onSubmit={handleSubmit}>
      <input {...getFieldProps("name")} />
      {isTouched("name") && getError("name") && (
        <span className="error">{getError("name")}</span>
      )}

      <input {...getFieldProps("email")} type="email" />
      {isTouched("email") && getError("email") && (
        <span className="error">{getError("email")}</span>
      )}

      <button type="submit" disabled={!isValid}>
        Submit
      </button>
    </form>
  );
};

useForm returns:

Property Description
values Current form values
errors Validation errors by field
touched Fields the user has interacted with
getFieldProps(name) Props to spread on inputs (value, onChange, etc.)
getError(name) First error message for a field
isRequired(name) Whether a field is required
isTouched(name) Whether user has modified this field
setField(name, value) Programmatically set a field value
handleSubmit Form submit handler
reset() Reset form to server values
isSyncing True while waiting for server validation
isValid True only when synced AND server says valid

Streams

Stream data to React components for real-time updates like feeds, chat, or infinite scrolling:

Define a stream prop:

use LiveReactIslands.Component,
  component: "MessageList",
  props: %{
    messages: {:stream, default: []}
  }

Push stream events from Elixir:

# Insert new item (prepends by default)
socket |> stream_insert(:messages, %{id: 1, text: "Hello"})

# Update existing item
socket |> stream_update(:messages, %{id: 1, text: "Hello, edited"})

# Delete an item
socket |> stream_delete(:messages, 1)

# Reset the entire stream
socket |> stream_reset(:messages)

Consume in React:

import { useStream } from "@live-react-islands/core";

const MessageList = ({ messages: messagesHandle }) => {
  const messages = useStream(messagesHandle, { limit: 100 });

  return (
    <ul>
      {messages.map((msg) => (
        <li key={msg.id}>{msg.text}</li>
      ))}
    </ul>
  );
};

Shared Context

Islands using :none (default) or :overwrite SSR strategies render into a shared React root via portals. This enables powerful patterns like drag-and-drop between islands, shared state managers, or animation libraries that need to coordinate across components.

Wrap all islands in a shared context:

// src/main.jsx
import { createHooks } from "@live-react-islands/core";
import { DndProvider } from "react-beautiful-dnd";
import islands from "./islands";

const SharedContextProvider = ({ children }) => (
  <DndProvider backend={HTML5Backend}>{children}</DndProvider>
);

const islandHooks = createHooks({
  islands,
  SharedContextProvider,
});

Now all your islands can participate in drag-and-drop with each other, even though they’re scattered across your LiveView template.

Note: Islands using :hydrate_root SSR strategy have their own isolated React root and do not participate in the shared context. Use :overwrite or :none if you need context sharing between islands.

Server-Side Rendering (SSR)

SSR improves initial page load performance by rendering React components on the server.

use LiveReactIslands.Component,
  component: "Counter",
  props: %{count: 0},
  ssr_strategy: :overwrite  # or :hydrate_root or :none (default)
Strategy Shared Root Best For
:none Yes Interactive components where initial render doesn’t matter
:overwrite Yes Most islands, especially when you need cross-island context (e.g., DnD)
:hydrate_root No Large islands where you want to avoid the overwrite flash

⚠️ SSR is optional. Many islands work perfectly without it. Enable SSR when initial paint, SEO, or perceived performance matter.

See the SSR Guide for complete setup instructions, caching strategies, and custom renderer implementation.

Requirements

Running Examples

cd examples/vite-example
mix deps.get
yarn install
yarn dev
mix phx.server  # in another terminal

Contributing

See CONTRIBUTING.md for development setup and guidelines.

License

MIT License - see LICENSE for details.