@live-react-islands/core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 David Czaplinski
3
+ Copyright (c) 2026 David Czaplinski
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md ADDED
@@ -0,0 +1,454 @@
1
+ # Live React Islands
2
+
3
+ **React-powered interactive islands** inside Phoenix LiveView. Harness the NPM ecosystem with server-driven state, real-time streams and zero-lag forms + SSR.
4
+
5
+ ![Banner](https://raw.githubusercontent.com/dcza/live-react-islands/main/docs/repository-open-graph.png)
6
+
7
+ ## Why Live React Islands?
8
+
9
+ **The best of both worlds!** ✨
10
+
11
+ 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:
12
+
13
+ - Use React components as "islands" within your LiveView templates
14
+ - Maintain server-side state in Elixir while rendering in React
15
+ - Send events from React to Elixir and push props back
16
+ - Use forms and streams in React with dedicated hooks
17
+ - Share global state across multiple islands
18
+ - Optionally server-side render for faster loads, SEO and no flicker
19
+
20
+ ## Comparison
21
+
22
+ Choose Live React Islands when you need rich, interactive React components without giving up LiveView’s server-driven simplicity.
23
+
24
+ | Feature | LiveView Only | LiveView + Alpine | Live React Islands (this) | Pure SPA (Next.js/Vite) |
25
+ | :--------------------- | :----------------------------------------- | :------------------- | :------------------------------------- | :---------------------- |
26
+ | **UI Ecosystem** | Limited (HEEX/Custom) | Small (Alpine kits) | **Infinite (NPM/React)** | **Infinite (NPM)** |
27
+ | **Interactivity** | Server-Roundtrip (JS hooks for edge cases) | Simple Client-side | **High-Fidelity / Fluid** | High-Fidelity / Fluid |
28
+ | **State Management** | Single (Server) | Fragmented | **Single (Server-Led)** | Dual (API + Client) |
29
+ | **Initial Load / SEO** | Instant | Instant | **Instant (SSR-enabled)** | Slow / Complex SSR |
30
+ | **JS Bundle Size** | ~0kb (Core only) | Small (+15kb) | **Large (React: ~100–150 kB gzipped)** | Large |
31
+ | **Developer Speed** | Very High | High (until complex) | **High (Asset Reuse)** | Low (API Plumbing) |
32
+ | **Component Logic** | Elixir Only | Mixed (Strings) | **JSX (Encapsulated)** | JSX |
33
+ | **Complexity Ceiling** | Struggles with app-like complexity | Hits wall on "State" | **High** | Very High |
34
+
35
+ ## When NOT to Use Live React Islands
36
+
37
+ - If your UI is mostly static or CRUD-heavy, plain LiveView is simpler and faster.
38
+ - If you only need light client-side behavior (toggles, dropdowns), LiveView + Alpine may be sufficient.
39
+ - If your application requires full offline support or heavy client-side state, a traditional SPA may be a better fit.
40
+
41
+ ## Installation
42
+
43
+ ### Elixir
44
+
45
+ Add to your `mix.exs`:
46
+
47
+ ```elixir
48
+ def deps do
49
+ [
50
+ {:live_react_islands, "~> 0.1.0"},
51
+ # For development SSR (optional):
52
+ {:live_react_islands_ssr_vite, "~> 0.1.0", only: :dev},
53
+ # For production SSR (optional):
54
+ {:live_react_islands_ssr_deno, "~> 0.1.0", only: :prod}
55
+ ]
56
+ end
57
+ ```
58
+
59
+ ### JavaScript
60
+
61
+ ```bash
62
+ npm install @live-react-islands/core
63
+ # For development SSR (optional):
64
+ npm install --save-dev @live-react-islands/vite-plugin-ssr
65
+ ```
66
+
67
+ ## Quick Start
68
+
69
+ ### 1. Create a React Component
70
+
71
+ ```jsx
72
+ // src/islands/Counter.jsx
73
+ const Counter = ({ count, title, pushEvent }) => {
74
+ return (
75
+ <div>
76
+ <h2>{title}</h2>
77
+ <p>Count: {count}</p>
78
+ <button onClick={() => pushEvent("increment", {})}>+1</button>
79
+ </div>
80
+ );
81
+ };
82
+
83
+ export default Counter;
84
+ ```
85
+
86
+ ### 2. Set Up the LiveView Hooks
87
+
88
+ ```jsx
89
+ // src/islands/index.js
90
+ export default { Counter: () => import("./Counter") };
91
+ ```
92
+
93
+ Islands can be lazy loaded to only load the JS used on the page.
94
+
95
+ ```jsx
96
+ // src/main.jsx
97
+ import { createHooks } from "@live-react-islands/core";
98
+ import islands from "./islands";
99
+
100
+ const islandHooks = createHooks({ islands });
101
+
102
+ // Add to your LiveSocket
103
+ let liveSocket = new LiveSocket("/live", Socket, {
104
+ hooks: { ...islandHooks },
105
+ });
106
+ ```
107
+
108
+ ### 3. Create an Elixir Component
109
+
110
+ ```elixir
111
+ defmodule MyAppWeb.Components.CounterIsland do
112
+ use LiveReactIslands.Component,
113
+ component: "Counter",
114
+ props: %{count: 0, title: "My Counter"}
115
+
116
+ def handle_event("increment", _params, socket) do
117
+ new_count = socket.assigns.count + 1
118
+ {:noreply, update_prop(socket, :count, new_count)}
119
+ end
120
+ end
121
+ ```
122
+
123
+ ### 4. Use in Your LiveView
124
+
125
+ ```elixir
126
+ defmodule MyAppWeb.CounterLive do
127
+ use MyAppWeb, :live_view
128
+ use LiveReactIslands.LiveView
129
+
130
+ def render(assigns) do
131
+ ~H"""
132
+ <.live_component module={MyAppWeb.Components.CounterIsland} id="counter-1" />
133
+ """
134
+ end
135
+ end
136
+ ```
137
+
138
+ ## How It Works
139
+
140
+ **React components receive these props automatically:**
141
+
142
+ | Prop | Description |
143
+ | -------------------- | --------------------------------- |
144
+ | `id` | The island's unique identifier |
145
+ | `pushEvent` | Function to send events to Elixir |
146
+ | All defined props | Current values from Elixir |
147
+ | All consumed globals | Current global state values |
148
+
149
+ ## Features
150
+
151
+ ### Props
152
+
153
+ Define props with default values. Props can be set from the template or updated from event handlers:
154
+
155
+ ```elixir
156
+ use LiveReactIslands.Component,
157
+ component: "Counter",
158
+ props: %{count: 0, title: "Default Title"}
159
+ ```
160
+
161
+ **Elixir components can override `init/2` for dynamic initialization:**
162
+
163
+ ```elixir
164
+ def init(assigns, socket) do
165
+ # Called once on mount, before SSR and first render
166
+ socket
167
+ |> update_prop(:computed, compute_value(assigns))
168
+ end
169
+ ```
170
+
171
+ **Updating props from Elixir:**
172
+
173
+ ```elixir
174
+ def handle_event("increment", _, socket) do
175
+ {:noreply, update_prop(socket, :count, socket.assigns.count + 1)}
176
+ end
177
+ ```
178
+
179
+ **Passing props from templates:**
180
+
181
+ ```heex
182
+ <.live_component module={CounterIsland} id="counter-1" title="Custom Title" />
183
+ ```
184
+
185
+ 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.
186
+
187
+ ```heex
188
+ <.live_component module={CounterIsland} id="counter-1" init_count={5} />
189
+ ```
190
+
191
+ ### Events
192
+
193
+ Send events from React to Elixir using `pushEvent`:
194
+
195
+ ```jsx
196
+ // React
197
+ <button onClick={() => pushEvent("save", { data: formData })}>Save</button>
198
+ ```
199
+
200
+ ```elixir
201
+ # Elixir
202
+ def handle_event("save", %{"data" => data}, socket) do
203
+ # Handle the event
204
+ {:noreply, socket}
205
+ end
206
+ ```
207
+
208
+ ### Global State
209
+
210
+ Share state across multiple islands. When a global changes, all islands that use it automatically rerender.
211
+
212
+ **Set up in your LiveView:**
213
+
214
+ ```elixir
215
+ defmodule MyAppWeb.DashboardLive do
216
+ use MyAppWeb, :live_view
217
+ use LiveReactIslands.LiveView, expose_globals: [:user, :theme]
218
+
219
+ def mount(_params, session, socket) do
220
+ {:ok, assign(socket, user: get_user(session), theme: "light")}
221
+ end
222
+ end
223
+ ```
224
+
225
+ **Consume in your island:**
226
+
227
+ ```elixir
228
+ use LiveReactIslands.Component,
229
+ component: "Header",
230
+ props: %{},
231
+ globals: [:user, :theme]
232
+ ```
233
+
234
+ **Optional globals** (won't error if not set):
235
+
236
+ ```elixir
237
+ globals: [:user?] # The ? suffix makes it optional
238
+ ```
239
+
240
+ The globals are passed as props to your React component:
241
+
242
+ ```jsx
243
+ const Header = ({ user, theme }) => (
244
+ <header className={theme}>Welcome, {user.name}</header>
245
+ );
246
+ ```
247
+
248
+ ### Forms with Server Validation
249
+
250
+ 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.
251
+
252
+ 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.
253
+
254
+ **Elixir component:**
255
+
256
+ ```elixir
257
+ defmodule MyAppWeb.Components.ContactFormIsland do
258
+ use LiveReactIslands.Component,
259
+ component: "ContactForm",
260
+ props: %{form: %{}}
261
+
262
+ alias MyApp.Contact
263
+
264
+ def init(_assigns, socket) do
265
+ changeset = Contact.changeset(%Contact{}, %{})
266
+ socket |> init_form(:form, changeset)
267
+ end
268
+
269
+ def handle_form(:validate, :form, attrs, socket) do
270
+ changeset = Contact.changeset(%Contact{}, attrs)
271
+ {:noreply, update_form(socket, :form, changeset)}
272
+ end
273
+
274
+ def handle_form(:submit, :form, attrs, socket) do
275
+ case Contact.create(attrs) do
276
+ {:ok, _contact} ->
277
+ {:noreply, init_form(socket, :form, Contact.changeset(%Contact{}, %{}))}
278
+ {:error, changeset} ->
279
+ {:noreply, update_form(socket, :form, changeset)}
280
+ end
281
+ end
282
+ end
283
+ ```
284
+
285
+ **React component:**
286
+
287
+ ```jsx
288
+ import { useForm } from "@live-react-islands/core";
289
+
290
+ const ContactForm = ({ form, pushEvent }) => {
291
+ const {
292
+ getFieldProps,
293
+ getError,
294
+ isRequired,
295
+ isTouched,
296
+ handleSubmit,
297
+ isValid,
298
+ } = useForm(form, pushEvent);
299
+
300
+ return (
301
+ <form onSubmit={handleSubmit}>
302
+ <input {...getFieldProps("name")} />
303
+ {isTouched("name") && getError("name") && (
304
+ <span className="error">{getError("name")}</span>
305
+ )}
306
+
307
+ <input {...getFieldProps("email")} type="email" />
308
+ {isTouched("email") && getError("email") && (
309
+ <span className="error">{getError("email")}</span>
310
+ )}
311
+
312
+ <button type="submit" disabled={!isValid}>
313
+ Submit
314
+ </button>
315
+ </form>
316
+ );
317
+ };
318
+ ```
319
+
320
+ **`useForm` returns:**
321
+
322
+ | Property | Description |
323
+ | ----------------------- | ----------------------------------------------------- |
324
+ | `values` | Current form values |
325
+ | `errors` | Validation errors by field |
326
+ | `touched` | Fields the user has interacted with |
327
+ | `getFieldProps(name)` | Props to spread on inputs (`value`, `onChange`, etc.) |
328
+ | `getError(name)` | First error message for a field |
329
+ | `isRequired(name)` | Whether a field is required |
330
+ | `isTouched(name)` | Whether user has modified this field |
331
+ | `setField(name, value)` | Programmatically set a field value |
332
+ | `handleSubmit` | Form submit handler |
333
+ | `reset()` | Reset form to server values |
334
+ | `isSyncing` | True while waiting for server validation |
335
+ | `isValid` | True only when synced AND server says valid |
336
+
337
+ ### Streams
338
+
339
+ Stream data to React components for real-time updates like feeds, chat, or infinite scrolling:
340
+
341
+ **Define a stream prop:**
342
+
343
+ ```elixir
344
+ use LiveReactIslands.Component,
345
+ component: "MessageList",
346
+ props: %{
347
+ messages: {:stream, default: []}
348
+ }
349
+ ```
350
+
351
+ **Push stream events from Elixir:**
352
+
353
+ ```elixir
354
+ # Insert new item (prepends by default)
355
+ socket |> stream_insert(:messages, %{id: 1, text: "Hello"})
356
+
357
+ # Update existing item
358
+ socket |> stream_update(:messages, %{id: 1, text: "Hello, edited"})
359
+
360
+ # Delete an item
361
+ socket |> stream_delete(:messages, 1)
362
+
363
+ # Reset the entire stream
364
+ socket |> stream_reset(:messages)
365
+ ```
366
+
367
+ **Consume in React:**
368
+
369
+ ```jsx
370
+ import { useStream } from "@live-react-islands/core";
371
+
372
+ const MessageList = ({ messages: messagesHandle }) => {
373
+ const messages = useStream(messagesHandle, { limit: 100 });
374
+
375
+ return (
376
+ <ul>
377
+ {messages.map((msg) => (
378
+ <li key={msg.id}>{msg.text}</li>
379
+ ))}
380
+ </ul>
381
+ );
382
+ };
383
+ ```
384
+
385
+ ### Shared Context
386
+
387
+ 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.
388
+
389
+ **Wrap all islands in a shared context:**
390
+
391
+ ```jsx
392
+ // src/main.jsx
393
+ import { createHooks } from "@live-react-islands/core";
394
+ import { DndProvider } from "react-beautiful-dnd";
395
+ import islands from "./islands";
396
+
397
+ const SharedContextProvider = ({ children }) => (
398
+ <DndProvider backend={HTML5Backend}>{children}</DndProvider>
399
+ );
400
+
401
+ const islandHooks = createHooks({
402
+ islands,
403
+ SharedContextProvider,
404
+ });
405
+ ```
406
+
407
+ Now all your islands can participate in drag-and-drop with each other, even though they're scattered across your LiveView template.
408
+
409
+ > **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.
410
+
411
+ ### Server-Side Rendering (SSR)
412
+
413
+ SSR improves initial page load performance by rendering React components on the server.
414
+
415
+ ```elixir
416
+ use LiveReactIslands.Component,
417
+ component: "Counter",
418
+ props: %{count: 0},
419
+ ssr_strategy: :overwrite # or :hydrate_root or :none (default)
420
+ ```
421
+
422
+ | Strategy | Shared Root | Best For |
423
+ | --------------- | ----------- | ----------------------------------------------------------------------- |
424
+ | `:none` | Yes | Interactive components where initial render doesn't matter |
425
+ | `:overwrite` | Yes | Most islands, especially when you need cross-island context (e.g., DnD) |
426
+ | `:hydrate_root` | No | Large islands where you want to avoid the overwrite flash |
427
+
428
+ > ⚠️ SSR is optional. Many islands work perfectly without it. Enable SSR when initial paint, SEO, or perceived performance matter.
429
+
430
+ 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.
431
+
432
+ ## Requirements
433
+
434
+ - Elixir >= 1.14
435
+ - Phoenix LiveView >= 1.0
436
+ - React 18 or 19
437
+ - Any JavaScript bundler (built-in SSR plugin for Vite)
438
+
439
+ ### Running Examples
440
+
441
+ ```bash
442
+ cd examples/vite-example
443
+ mix deps.get
444
+ cd assets && yarn install && yarn dev # in one terminal
445
+ cd .. && mix phx.server # in another terminal
446
+ ```
447
+
448
+ ## Contributing
449
+
450
+ See [CONTRIBUTING.md](https://github.com/dcza/live-react-islands/blob/main/CONTRIBUTING.md) for development setup and guidelines.
451
+
452
+ ## License
453
+
454
+ MIT License - see [LICENSE](https://github.com/dcza/live-react-islands/blob/main/LICENSE) for details.
@@ -3,7 +3,9 @@ import { type IslandsMap, type ContextProviderComponent } from "./types";
3
3
  interface LiveViewHook {
4
4
  el: HTMLElement;
5
5
  liveSocket: {
6
- on: (event: string, callback: (e: Event) => void) => void;
6
+ on: (event: string, callback: (e: CustomEvent<{
7
+ kind: string;
8
+ }>) => void) => void;
7
9
  };
8
10
  pushEvent: (event: string, payload: any, callback?: (reply: any, ref: any) => void) => void;
9
11
  pushEventTo: (target: string, event: string, payload: any) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.ts"],"names":[],"mappings":"AAAA,OAAuB,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,wBAAwB,EAM9B,MAAM,SAAS,CAAC;AAMjB,UAAU,YAAY;IACpB,EAAE,EAAE,WAAW,CAAC;IAChB,UAAU,EAAE;QACV,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC,EAAE,KAAK,KAAK,IAAI,KAAK,IAAI,CAAC;KAC3D,CAAC;IACF,SAAS,EAAE,CACT,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,GAAG,EACZ,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,KACtC,IAAI,CAAC;IACV,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,KAAK,IAAI,CAAC;IACnE,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,KAAK,IAAI,CAAC;CACrE;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,UAAU,CAAC;IACpB,qBAAqB,CAAC,EAAE,wBAAwB,CAAC;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAwED,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EAAE,UAAe,EACxB,qBAA2C,EAC3C,OAAwB,GACzB,EAAE,kBAAkB;;iBASR,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;kBAC3B,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;mBAC3B,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;;EA0N1C"}
1
+ {"version":3,"file":"createHooks.d.ts","sourceRoot":"","sources":["../src/createHooks.ts"],"names":[],"mappings":"AAAA,OAAuB,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EACL,KAAK,UAAU,EAGf,KAAK,wBAAwB,EAM9B,MAAM,SAAS,CAAC;AAMjB,UAAU,YAAY;IACpB,EAAE,EAAE,WAAW,CAAC;IAChB,UAAU,EAAE;QACV,EAAE,EAAE,CACF,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,CAAC,CAAC,EAAE,WAAW,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,KAAK,IAAI,KACjD,IAAI,CAAC;KACX,CAAC;IACF,SAAS,EAAE,CACT,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,GAAG,EACZ,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,KACtC,IAAI,CAAC;IACV,WAAW,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,KAAK,IAAI,CAAC;IACnE,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,KAAK,IAAI,CAAC;CACrE;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,UAAU,CAAC;IACpB,qBAAqB,CAAC,EAAE,wBAAwB,CAAC;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAwED,wBAAgB,WAAW,CAAC,EAC1B,OAAO,EAAE,UAAe,EACxB,qBAA2C,EAC3C,OAAwB,GACzB,EAAE,kBAAkB;;iBASR,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;kBAC3B,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;mBAC3B,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI;;EAgO1C"}
package/dist/index.esm.js CHANGED
@@ -380,7 +380,14 @@ function createHooks({ islands: islandsMap = {}, SharedContextProvider = NullCon
380
380
  }
381
381
  if (!navigationListenerAttached) {
382
382
  navigationListenerAttached = true;
383
- this.liveSocket.on("phx:page-loading-start", () => {
383
+ this.liveSocket.on("phx:page-loading-start", (e) => {
384
+ // Patches (e.g. push_patch tab switches) stay within the same
385
+ // LiveView mount and globals version counter, so islands that
386
+ // remain mounted through the patch must keep their cached
387
+ // globals. Only a real navigation (new mount, version resets
388
+ // to 0 server-side) needs the stale client-side version cleared.
389
+ if (e.detail?.kind === "patch")
390
+ return;
384
391
  globalsRequested = false;
385
392
  managerState = manager.resetGlobals(managerState);
386
393
  });
@@ -469,7 +476,7 @@ function createHooks({ islands: islandsMap = {}, SharedContextProvider = NullCon
469
476
  const el = this.el;
470
477
  pendingMounts.add(elId);
471
478
  (async () => {
472
- const islandConfig = await resolveIslandConfig(islandsMap, componentName, SharedContextProvider);
479
+ const islandConfig = await resolveIslandConfig(islandsMap, componentName, NullContextProvider$1);
473
480
  if (!pendingMounts.has(elId))
474
481
  return;
475
482
  pendingMounts.delete(elId);