@live-react-islands/vite-plugin-ssr
v0.1.0
Published
Vite plugin for LiveReactIslands SSR development server
Maintainers
Readme
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.

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:
- Use React components as "islands" within your LiveView templates
- Maintain server-side state in Elixir while rendering in React
- Send events from React to Elixir and push props back
- Use forms and streams in React with dedicated hooks
- Share global state across multiple islands
- Optionally server-side render for faster loads, SEO and no flicker
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
- If your UI is mostly static or CRUD-heavy, plain LiveView is simpler and faster.
- If you only need light client-side behavior (toggles, dropdowns), LiveView + Alpine may be sufficient.
- If your application requires full offline support or heavy client-side state, a traditional SPA may be a better fit.
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}
]
endJavaScript
npm install @live-react-islands/core
# For development SSR (optional):
npm install --save-dev @live-react-islands/vite-plugin-ssrQuick 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
end4. 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
endHow 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))
endUpdating props from Elixir:
def handle_event("increment", _, socket) do
{:noreply, update_prop(socket, :count, socket.assigns.count + 1)}
endPassing 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}
endGlobal 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
endConsume 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 optionalThe 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
endReact 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_rootSSR strategy have their own isolated React root and do not participate in the shared context. Use:overwriteor:noneif 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
- Elixir >= 1.14
- Phoenix LiveView >= 1.0
- React 18 or 19
- Any JavaScript bundler (built-in SSR plugin for Vite)
Running Examples
cd examples/vite-example
mix deps.get
yarn install
yarn dev
mix phx.server # in another terminalContributing
See CONTRIBUTING.md for development setup and guidelines.
License
MIT License - see LICENSE for details.
