Skip to content

Instantly share code, notes, and snippets.

@niteria
Created January 21, 2026 19:33
Show Gist options
  • Select an option

  • Save niteria/0311e4c721b0341ff1c47d1d2f549677 to your computer and use it in GitHub Desktop.

Select an option

Save niteria/0311e4c721b0341ff1c47d1d2f549677 to your computer and use it in GitHub Desktop.
PureScript Halogen + Radix UI: FFI Feasibility Analysis

PureScript Halogen + Radix UI: FFI Feasibility Analysis

Executive Summary

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.


The Core Problem

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.RootDialog.TriggerDialog.Content) relies entirely on React Context for state sharing between parts. Halogen has no way to participate in this.


How Radix UI Components Work

API Patterns

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>

Key Architectural Features

  1. React Context for State Sharing: Components like Dialog.Root create React Context that child components consume
  2. Controlled vs Uncontrolled: Most components support both modes via open/defaultOpen props
  3. asChild Prop and Slot Composition: Merges props onto child elements for polymorphic rendering
  4. Ref Forwarding: Components use React.forwardRef extensively
  5. Accessibility: WAI-ARIA compliant, focus management, keyboard navigation built-in

Key Technical Challenges

1. React Context

Radix components communicate via React Context. Halogen has no equivalent mechanism.

2. Compound Components

You can't split Radix sub-components across the Halogen/React boundary - all related sub-components must stay within React.

3. Ref Forwarding

Radix uses forwardRef extensively for focus management and DOM access. Halogen's RefLabel system doesn't map directly.

4. asChild Pattern

Radix's polymorphic rendering requires React-compatible children that forward refs properly.


Viable Approaches (Ranked by Practicality)

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

React Islands Architecture

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.

Architecture Diagram

┌─────────────────────────────────────────────────────────┐
│  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)  │  │                 │      │  │
│  │   └─────────────────┘  └─────────────────┘      │  │
│  └──────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

Implementation

1. The Halogen Side

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

2. The FFI Bridge (JavaScript)

// 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);
  }
};

3. The FFI Declarations (PureScript)

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 Unit

Communication Patterns

Halogen → React (Props Down)

handleAction = case _ of
  OpenDialog -> do
    H.modify_ _ { dialogOpen = true }
    -- React island re-renders via Receive

React → Halogen (Callbacks Up)

Options for wiring callbacks:

  1. Custom DOM Events: React dispatches, Halogen subscribes
  2. Mutable Ref: Shared Ref that Halogen polls or subscribes to
  3. Effect Callback: Pass an Effect that modifies a Ref Halogen watches

Challenges with React Islands

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

When React Islands Make Sense

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-hooks directly)
  • Need many Radix components throughout the app
  • Heavy interaction between Halogen and React state

Recommendations

For New Projects

Use purescript-react-basic-hooks + Radix FFI or purescript-react-halo (Halogen-style eval on React).

For Existing Halogen Projects

  1. If accessibility is critical: Consider gradual migration to React-based stack
  2. If Halogen is mandatory: Use React Islands, but be aware of complexity
  3. For specific features: Mount isolated React roots for just the Radix components needed

FFI Binding Strategy

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)

Relevant Libraries

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.


Conclusion

Creating Halogen-to-Radix bindings faces a fundamental architectural mismatch. The most practical paths are:

  1. Adopt purescript-react-basic-hooks or purescript-react-halo instead of Halogen for React interop
  2. Use React Islands if you must keep Halogen but need specific Radix components
  3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment