Creating FFI bindings from PureScript Halogen to Radix UI components is technically feasible but significantly challenging. The fundamental architectural mismatch between Halogen (virtual DOM agnostic, using its own rendering) and Radix UI (deeply coupled to React) means a direct FFI approach is impractical. However, several viable alternative approaches exist.
There's a fundamental architectural mismatch between Halogen and Radix UI:
| Halogen | Radix UI |
|---|---|
| Custom virtual DOM | React's VDOM |
| Custom driver-based rendering | React reconciliation |
| No context mechanism | Heavy React Context usage |
| RefLabel system | React.forwardRef |
Radix's compound component pattern (e.g., Dialog.Root → Dialog.Trigger → Dialog.Content) relies entirely on React Context for state sharing between parts. Halogen has no way to participate in this.
Radix UI uses a compound component pattern with namespace-based sub-components:
<Dialog.Root>
<Dialog.Trigger asChild>
<button>Open</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title />
<Dialog.Description />
<Dialog.Close />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>- React Context for State Sharing: Components like
Dialog.Rootcreate React Context that child components consume - Controlled vs Uncontrolled: Most components support both modes via
open/defaultOpenprops asChildProp and Slot Composition: Merges props onto child elements for polymorphic rendering- Ref Forwarding: Components use
React.forwardRefextensively - Accessibility: WAI-ARIA compliant, focus management, keyboard navigation built-in
Radix components communicate via React Context. Halogen has no equivalent mechanism.
You can't split Radix sub-components across the Halogen/React boundary - all related sub-components must stay within React.
Radix uses forwardRef extensively for focus management and DOM access. Halogen's RefLabel system doesn't map directly.
Radix's polymorphic rendering requires React-compatible children that forward refs properly.
| Approach | Difficulty | Timeline | Notes |
|---|---|---|---|
Use purescript-react-basic-hooks directly |
Medium | 3-4 weeks | Abandon Halogen, get full React/Radix compat |
Use purescript-react-halo |
Medium | 3-4 weeks | Halogen-style eval patterns on top of React |
| React Islands in Halogen | High | 6-8 weeks | Mount React roots in Halogen DOM, message-pass between them |
| Rebuild Radix in pure Halogen | Very High | 3-6 months | Full control but massive effort |
The "React Islands" pattern allows you to embed React components (like Radix UI) inside specific DOM nodes managed by Halogen, while keeping most of your app in pure Halogen.
┌─────────────────────────────────────────────────────────┐
│ Halogen Application │
│ │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Halogen Header │ │ Halogen Sidebar │ │
│ └──────────────────┘ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Halogen Content Area │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ ★ React Island (Radix Dialog) │ │ │
│ │ │ - Mounted via ReactDOM.createRoot() │ │ │
│ │ │ - Managed by Halogen lifecycle │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ ★ React Island │ │ Halogen Form │ │ │
│ │ │ (Radix Select) │ │ │ │ │
│ │ └─────────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Create a Halogen component that manages a DOM element where React will mount:
module Component.ReactIsland where
import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Properties as HP
import Web.HTML.HTMLElement (HTMLElement)
type Input =
{ dialogOpen :: Boolean
, onOpenChange :: Boolean -> Effect Unit
}
data Action
= Initialize
| Finalize
| Receive Input
component :: forall q o m. MonadAff m => H.Component q Input o m
component = H.mkComponent
{ initialState: identity
, render
, eval: H.mkEval $ H.defaultEval
{ handleAction = handleAction
, initialize = Just Initialize
, finalize = Just Finalize
, receive = Just <<< Receive
}
}
where
render _ =
-- This div becomes the React root
HH.div [ HP.ref (H.RefLabel "react-root") ] []
handleAction = case _ of
Initialize -> do
-- Get the DOM element and mount React
mEl <- H.getRef (H.RefLabel "react-root")
for_ mEl \el -> do
state <- H.get
H.liftEffect $ mountReactDialog el state
Receive newInput -> do
-- Re-render React with new props
mEl <- H.getRef (H.RefLabel "react-root")
for_ mEl \el ->
H.liftEffect $ updateReactDialog el newInput
Finalize -> do
-- Clean up React root
mEl <- H.getRef (H.RefLabel "react-root")
for_ mEl \el ->
H.liftEffect $ unmountReactDialog el// ReactIsland.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import * as Dialog from '@radix-ui/react-dialog';
// Store roots to manage lifecycle
const roots = new WeakMap();
export const mountReactDialog = (element) => (props) => () => {
const root = createRoot(element);
roots.set(element, root);
root.render(
<Dialog.Root open={props.dialogOpen} onOpenChange={props.onOpenChange}>
<Dialog.Trigger asChild>
<button>Open Dialog</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title>Dialog Title</Dialog.Title>
<Dialog.Description>Content here...</Dialog.Description>
<Dialog.Close asChild>
<button>Close</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
export const updateReactDialog = (element) => (props) => () => {
const root = roots.get(element);
if (root) {
root.render(
<Dialog.Root open={props.dialogOpen} onOpenChange={props.onOpenChange}>
{/* ... same as above */}
</Dialog.Root>
);
}
};
export const unmountReactDialog = (element) => () => {
const root = roots.get(element);
if (root) {
root.unmount();
roots.delete(element);
}
};module ReactIsland where
import Effect (Effect)
import Web.HTML.HTMLElement (HTMLElement)
type DialogProps =
{ dialogOpen :: Boolean
, onOpenChange :: Boolean -> Effect Unit
}
foreign import mountReactDialog :: HTMLElement -> DialogProps -> Effect Unit
foreign import updateReactDialog :: HTMLElement -> DialogProps -> Effect Unit
foreign import unmountReactDialog :: HTMLElement -> Effect UnithandleAction = case _ of
OpenDialog -> do
H.modify_ _ { dialogOpen = true }
-- React island re-renders via ReceiveOptions for wiring callbacks:
- Custom DOM Events: React dispatches, Halogen subscribes
- Mutable Ref: Shared
Refthat Halogen polls or subscribes to - Effect Callback: Pass an
Effectthat modifies aRefHalogen watches
| Challenge | Severity | Mitigation |
|---|---|---|
| Two-way sync | High | State of truth lives in Halogen; React is "dumb" renderer |
| Lifecycle management | Medium | Careful use of Initialize/Finalize |
| Bundle size | Medium | You're shipping both Halogen + React |
| Type safety at boundary | Medium | FFI types can drift from JS implementation |
| Portal rendering | Low | Radix Portals render outside the island div (usually fine) |
| Focus management | Medium | Radix handles focus, but transitions in/out of islands need care |
Good fit:
- Existing large Halogen codebase
- Need only a few complex accessible components (Dialog, Dropdown, Tooltip)
- Team knows Halogen well, doesn't want full rewrite
Bad fit:
- New project (just use
purescript-react-basic-hooksdirectly) - Need many Radix components throughout the app
- Heavy interaction between Halogen and React state
Use purescript-react-basic-hooks + Radix FFI or purescript-react-halo (Halogen-style eval on React).
- If accessibility is critical: Consider gradual migration to React-based stack
- If Halogen is mandatory: Use React Islands, but be aware of complexity
- For specific features: Mount isolated React roots for just the Radix components needed
When creating Radix FFI bindings:
-- 1. Use row types for flexible props
type DialogRootPropsRow r =
( open :: UndefinedOr Boolean
, defaultOpen :: UndefinedOr Boolean
, onOpenChange :: UndefinedOr (Boolean -> Effect Unit)
, modal :: UndefinedOr Boolean
| r
)
-- 2. Provide both record and builder APIs
dialogRoot :: forall r. Record (DialogRootPropsRow r) -> Array JSX -> JSX
-- 3. Type event handlers carefully
type DataState = "open" | "closed" -- Use sum types for data attributes
-- 4. Handle asChild with careful typing
type AsChildProps r = (asChild :: Boolean | r)| Library | Purpose |
|---|---|
purescript-react-basic-hooks |
Modern React hooks FFI for PureScript |
purescript-react-halo |
Halogen-style eval patterns on React |
purescript-halogen-hooks |
React-style hooks for Halogen |
purescript-react-basic-dom |
DOM bindings for react-basic |
Note: No existing PureScript Radix bindings exist - any solution requires creating FFI bindings from scratch.
Creating Halogen-to-Radix bindings faces a fundamental architectural mismatch. The most practical paths are:
- Adopt
purescript-react-basic-hooksorpurescript-react-haloinstead of Halogen for React interop - Use React Islands if you must keep Halogen but need specific Radix components
- Accept the tradeoff that Radix's compound component patterns require staying within React's ecosystem
The effort is substantial but feasible, with the React-based approaches offering the best return on investment.