# 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`:
```elixir
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
```bash
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
```jsx
// 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
```jsx
// src/islands/index.js
export default { Counter: () => import("./Counter") };
```
Islands can be lazy loaded to only load the JS used on the page.
```jsx
// 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
```elixir
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
```elixir
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:
```elixir
use LiveReactIslands.Component,
component: "Counter",
props: %{count: 0, title: "Default Title"}
```
**Elixir components can override `init/2` for dynamic initialization:**
```elixir
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:**
```elixir
def handle_event("increment", _, socket) do
{:noreply, update_prop(socket, :count, socket.assigns.count + 1)}
end
```
**Passing props from templates:**
```heex
<.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.
```heex
<.live_component module={CounterIsland} id="counter-1" init_count={5} />
```
### Events
Send events from React to Elixir using `pushEvent`:
```jsx
// React
<button onClick={() => pushEvent("save", { data: formData })}>Save</button>
```
```elixir
# 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:**
```elixir
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:**
```elixir
use LiveReactIslands.Component,
component: "Header",
props: %{},
globals: [:user, :theme]
```
**Optional globals** (won't error if not set):
```elixir
globals: [:user?] # The ? suffix makes it optional
```
The globals are passed as props to your React component:
```jsx
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:**
```elixir
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:**
```jsx
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:**
```elixir
use LiveReactIslands.Component,
component: "MessageList",
props: %{
messages: {:stream, default: []}
}
```
**Push stream events from Elixir:**
```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:**
```jsx
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:**
```jsx
// 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.
```elixir
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](https://github.com/dcza/live-react-islands/blob/main/docs/SSR.md)** 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
```bash
cd examples/vite-example
mix deps.get
yarn install
yarn dev
mix phx.server # in another terminal
```
## Contributing
See [CONTRIBUTING.md](https://github.com/dcza/live-react-islands/blob/main/CONTRIBUTING.md) for development setup and guidelines.
## License
MIT License - see [LICENSE](https://github.com/dcza/live-react-islands/blob/main/LICENSE) for details.