Skip to content

Islands

Most of a Fresco application is interpreted Hiccup: defview bodies, h/sub reads, event vectors. Some regions are better written in React itself: a component that needs a hook, a vendor widget that keeps its own state, pointer handling that updates on every move, or a screen that is React-shaped by design.

An island is a React component, raw or UIx, mounted through h/defhost under the same root, frame and app-db as the Hiccup around it. When it needs Fresco state it uses two hooks from re-frame.fresco.native: n/use-sub reads a subscription and n/use-frame returns the frame's operations. An island that reads nothing needs neither; a vendor widget fed by props is an island with nothing from that namespace in it.

[...] always means interpreted Hiccup. A React element is never interpreted; it passes through unchanged. An application that never requires re-frame.fresco.native includes none of its code.

The same row three ways

A todo row reads one subscription and dispatches one click. As an ordinary Fresco view:

(ns app.todos.row
  (:require [re-frame.fresco :as h]))

(h/defview todo-row [{:keys [id]}]
  (let [{:keys [title done?]} (h/sub [:todo/by-id id])]
    [:li {:class    (if done? "todo done" "todo")
          :on-click [:todo/toggle id]}
     title]))

The parent renders [todo-row {:key id :id id}]. Every row starts here, and most stay here.

The same row as a UIx island:

(ns app.todos.row
  (:require [re-frame.fresco :as h]
            [re-frame.fresco.native :as n]
            [uix.core :refer [defui $]]))

(defui todo-row* [{:keys [id]}]
  (let [{:keys [dispatch]}    (n/use-frame)
        {:keys [title done?]} (n/use-sub [:todo/by-id id])]
    ($ :li {:class    (if done? "todo done" "todo")
            :on-click (fn [_] (dispatch [:todo/toggle id]))}
       title)))

(defn todo-row-react [^js props]
  ($ todo-row* {:id (.-id props)}))

(h/defhost todo-row todo-row-react)

A defui reads its props from UIx's own props object, which only UIx's $ builds. So the host mounts a plain function that hands the JavaScript props to $. That wrapper is the only extra code the UIx version needs.

The same row in raw React:

(ns app.todos.row
  (:require ["react" :as react]
            [re-frame.fresco :as h]
            [re-frame.fresco.native :as n]))

(defn todo-row* [^js props]
  (let [id                    (.-id props)
        {:keys [dispatch]}    (n/use-frame)
        {:keys [title done?]} (n/use-sub [:todo/by-id id])]
    (react/createElement "li"
      #js {:className (if done? "todo done" "todo")
           :onClick   (fn [_] (dispatch [:todo/toggle id]))}
      title)))

(h/defhost todo-row todo-row*)

Both islands mount as [todo-row {:key id :id id}], exactly as the view did; the parent cannot tell which of the three it renders. Use UIx when the region is substantial React-first code, and raw React when you would rather not add a dependency. Ordinary React hooks are legal in either, because you own the component and its call order.

h/defhost names the crossing for tools and tests and carries its server policy. [:>] mounts a one-off without a name (Interop).

Reading and dispatching from an island

n/use-sub is a real React hook, so React's rules apply: call it at the top level of the component, unconditionally, once per read. It reads through the same subscription cache a defview uses, so the value, the wake-up on commit, and the entry in Xray are the same.

Read API Legal context Rule
h/sub synchronous Fresco view body ordinary function call; branches, loops and helpers are legal
n/use-sub React component React hook; top level and unconditional

Two n/use-sub calls are two React subscriptions, where a defview body's several h/sub reads share one. An island that reads a dozen keys should probably be a defview.

n/use-frame returns {:frame :dispatch :dispatch-sync :subscribe} for the frame the island is mounted in. The map is the same object on every render, so it is safe to close over and to use in effect dependencies. If the frame is destroyed and recreated under the same id, a callback still holding the old operations drops the call and emits :rf.error/frame-destroyed without throwing or writing to the new frame. Take the operations from the live island, never from a global.

Both hooks resolve the frame from the island's position in the tree. No argument reaches a sibling frame. Rendering outside every frame raises :rf.error/no-frame-context.

Keep high-rate work inside the island. A drag keeps the pointer position in react/useState and dispatches one event when the pointer is released (Ephemeral state).

Hot reload creates a new component, so React remounts the subtree and local hook state resets on save. State that must survive a save belongs in app-db and comes back through n/use-sub.

An island is client-only unless its host declares {:server :render}. During server rendering n/use-sub performs the same one-off snapshot read as h/sub and installs no live subscription (SSR and hydration).

When not to write an island

Do not cross for speed without a reproducible, measured interaction problem traced to this region. Read placement, unstable props, excessive event volume and a large DOM are fixed at the Fresco level first.

Keep form controls interpreted. An <input> inside an island loses Fresco's same-turn value repair, selection preservation, IME protection and ::h/revision handling, and moving it there does not make typing faster.

Do not create islands for stylistic consistency. Islands throughout the application amount to a change of view layer; use the UIx adapter for that.

Native screens

A canvas editor, diagramming surface or vendor-grid screen may be React-shaped from its first design. Implement that screen in React under the same adapter, root and frames. Only the view implementation changes: it does not need a second state owner or an independent React root. Create an independent root only when you need isolation; it does not make anything faster.

Verify every crossing

After crossing, rerun the checks Fresco can no longer perform inside the React subtree:

  • DOM and interaction parity
  • focus and selection
  • frame routing
  • SSR and hydration
  • cleanup and StrictMode behaviour
  • the original performance script

Xray shows the island's n/use-sub reads. It does not time the host, and it labels the inner React tree as opaque; React DevTools shows the host under its defhost name.

Troubleshooting

Symptom Cause Fix
n/use-sub or n/use-frame raises :rf.error/no-frame-context The component mounted outside a Fresco frame provider Mount it under the application root; in tests, mount it with hm/mount! (Testing)
A click inside an island or a directly returned element does nothing An event vector or h/event at a raw React prop, where nothing converts it Dispatch from n/use-frame in an island, or from (rf/capture-frame) in a view body
A defui mounted through h/defhost sees empty props UIx reads props from its own object, which only $ builds Mount a plain function that calls $ with the JavaScript props
Local island state resets after each code save Hot reload creates a new component and React remounts it Expected; move persistent state to app-db
The React rewrite does not improve the measurement Hiccup construction was not the cost Remove the escape and look at view structure and event volume
A controlled field loses caret or composition behaviour It was moved into an island Keep the field interpreted, or implement the full React contract yourself

Advanced

A hot view may return a React element

There is one step short of an island. A defview whose Hiccup construction is the measured cost may return a React element directly. The view keeps its identity, props memoization, frame, h/sub reads, lifecycle and Xray name; only the returned subtree skips interpretation. Nothing inside that element is converted (no event vectors, class collections or controlled-field repair), so a callback is a plain function that carries the frame by capturing it:

(ns app.todos.row
  (:require ["react" :as react]
            [re-frame.core :as rf]
            [re-frame.fresco :as h]))

(h/defview todo-row [{:keys [id]}]
  (let [{:keys [title done?]} (h/sub [:todo/by-id id])
        {:keys [dispatch]}    (rf/capture-frame)]
    (react/createElement "li"
      #js {:className (if done? "todo done" "todo")
           :onClick   (fn [_] (dispatch [:todo/toggle id]))}
      title)))

In Fresco's own benchmark, on a 200-view mount, the direct return recovered 19.2% of mount time, with an observed range of 7.0–31.4% over fifteen rounds. Treat that as a possible saving on a page made of many views, not a promise for yours.

Keep the escape only when the measured interaction improves materially: at least 20%, at least 2 ms at p95, or enough to move a user-visible budget from fail to pass. Otherwise remove it; a second authoring style with no measured benefit is a maintenance cost. Performance describes the method, and The escape ladder lists the options in order.

Hooks remain illegal in a defview body, which may branch and loop. A view that needs a hook is an island.