Skip to content

[RFC] Support ref as prop for React 19 compatibility #8104

@wickedev

Description

@wickedev

Motivation

React 19 has deprecated forwardRef and now allows passing ref as a regular prop:

// React 19 (JavaScript/TypeScript)
function Button({ ref, children }) {
  return <button ref={ref}>{children}</button>;
}

Current State in ReScript 12: PR #7420 removed the compiler error that blocked ref as a prop, but type support is incomplete:

// ReScript 12 - Compiles but has type errors
@react.component
let make = (~ref: option<JsxDOM.domRef>=?, ~children) => {
  <button ref={?ref}>{children}</button>
  // ❌ Type error: option<option<JsxDOM.domRef>> vs option<JsxDOM.domRef>
  // The ?ref syntax wraps it in option again, creating nested options
}

Note: ReactDOM.domRef is an alias for JsxDOM.domRef (defined in ReactDOM.res:81). They are interchangeable, and both should work once this proposal is implemented. The current limitation affects both types equally because the JSX transform treats ref as a special prop rather than allowing it as a regular prop type.

This creates an incomplete implementation:

  1. ✅ PR Do not error on ref as prop #7420 removed the compilation error (source)
  2. ✅ ReScript official docs strongly recommend "passing ref as a prop" (docs)
  3. ✅ React 19 makes ref as prop the standard pattern
  4. ❌ But the type system doesn't properly support it yet

Current Workaround

We currently need to use custom callback props:

@react.component
let make = (~setButtonRef: option<ReactDOM.domRef => unit>=?, ~children) => {
  <button
    ref=?{setButtonRef->Belt.Option.map(cb =>
      ReactDOM.Ref.callbackDomRef(cb)
    )}>
    {children}
  </button>
}

// Usage
let buttonRef = React.useRef(Nullable.null)
<Button setButtonRef={element => buttonRef.current = element}>
  {React.string("Click")}
</Button>

This works but:

  • More verbose than standard React 19 pattern
  • Not consistent with broader React ecosystem
  • Conflicts with ReScript's own documentation

Proposal

Allow ref as a regular prop when React 19 is detected:

// Should work in React 19 mode
@react.component
let make = (~ref: option<ReactDOM.domRef>=?, ~children) => {
  <button ref={?ref}>{children}</button>
}

// Usage (standard React 19 pattern)
let buttonRef = React.useRef(Nullable.null)
<Button ref={buttonRef}>
  {React.string("Click")}
</Button>

Technical Approach

I've analyzed the actual codebase (latest main branch) and identified the exact modification points needed.

1. React Version Configuration

Current jsx_common.ml config structure (line 4-9):

type jsx_config = {
  mutable version: int;
  mutable module_: string;
  mutable nested_modules: string list;
  mutable has_component: bool;
}

Proposed addition:

type jsx_config = {
  mutable version: int;
  mutable module_: string;
  mutable nested_modules: string list;
  mutable has_component: bool;
  mutable react_version: int;  (* Add this field, default: 18 *)
}

Configuration Options

I've identified three possible approaches for React version detection:

Option A: Explicit rescript.json Configuration

{
  "jsx": {
    "version": 4,
    "module": "React",
    "reactVersion": 19
  }
}
Pros Cons
✅ Simple implementation ❌ Manual configuration required
✅ Uses existing Ext_json_parse ❌ Can get out of sync with actual React version
✅ Works in all environments
✅ Clear user intent

Option B: Auto-detect from package.json

let detect_react_version () : int =
  (* Parse package.json, extract react version *)
  (* Handle: "19.0.0", "^19.0.0", "~19.0.0", ">=19", "19.x", etc. *)
Pros Cons
✅ Zero configuration ("just works") ❌ Complex version string parsing (see below)
✅ Always matches installed version ❌ Monorepo: which package.json to use?
❌ Playground/REPL: no package.json exists
❌ pnpm workspace protocol: "workspace:*"
❌ npm tags: "next", "canary", "latest"
❌ Local/git refs: "file:...", "git+https://..."
❌ npm aliases: "npm:react@19"
⚠️ Version string parsing complexity
# Semver ranges to handle:
"19.0.0"           # exact
"^19.0.0"          # caret (>=19.0.0 <20.0.0)
"~19.0.0"          # tilde (>=19.0.0 <19.1.0)
">=19.0.0"         # range
"19.x" / "19.*"    # wildcard
">18.0.0 <20.0.0"  # complex range

# Non-semver values:
"next" / "canary"  # npm tags
"latest"           # npm tag
"workspace:*"      # pnpm workspace
"file:../react"    # local path
"npm:react@19"     # npm alias
"git+https://..."  # git URL

A regex like /^[\^~>=<]*(\d+)/ handles most cases, but edge cases require fallback logic.

Option C: Hybrid Approach

{
  "jsx": {
    "version": 4,
    "module": "React",
    "reactVersion": "auto"  // or explicit: 18, 19
  }
}

Behavior:

  • "auto" → Attempt package.json detection, fallback to 18
  • 18 / 19 → Explicit override (for edge cases or preference)
  • Omitted → Default to 18 (backward compatible)
Pros Cons
✅ Best of both worlds ⚠️ Slightly more complex implementation
✅ Zero-config for most users
✅ Escape hatch for edge cases
✅ Backward compatible

2. Modify JSX v4 Transform

Update compiler/syntax/src/jsx_v4.ml at two key locations:

Location 1 - ref prop type handling (line 142-164, specifically line 148-159):

(* Current code in make_props_type_params function *)
(* TODO: Worth thinking how about "ref_" or "_ref" usages *)  (* line 147 *)
else if label = "ref" then
  match interior_type with
  | {ptyp_desc = Ptyp_any} -> Some (ref_type_var loc)
  | _ ->
    (* Strip explicit Js.Nullable.t in case of forwardRef *)
    if strip_explicit_js_nullable_of_ref then
      strip_js_nullable interior_type
    else Some interior_type

(* Modified for React 19 *)
else if label = "ref" then
  if config.react_version >= 19 then
    (* React 19: treat ref as a normal prop, no special type handling *)
    match interior_type with
    | {ptyp_desc = Ptyp_any} -> Some (Typ.var ~loc (safe_type_from_value (Labelled {txt = label; loc = Location.none})))
    | _ -> Some interior_type
  else
    (* React 18 and below: original behavior *)
    match interior_type with
    | {ptyp_desc = Ptyp_any} -> Some (ref_type_var loc)
    | _ ->
      if strip_explicit_js_nullable_of_ref then
        strip_js_nullable interior_type
      else Some interior_type

Location 2 - forwardRef ref handling (line 316-331):

(* Current code in recursively_transform_named_args_for_make function *)
if txt = "ref" then
  let type_ =
    match pattern with
    | {ppat_desc = Ppat_constraint (_, type_)} -> Some type_
    | _ -> None
  in
  (* The ref arguement of forwardRef should be optional *)
  ( ( Optional {txt = "ref"; loc = Location.none},
      None,
      pattern,
      txt,
      pattern.ppat_loc,
      type_ )
    :: args,
    newtypes,
    core_type )

(* Modified for React 19 - ref becomes a regular labeled prop *)
if txt = "ref" then
  if config.react_version >= 19 then
    (* React 19: ref is a normal labeled prop, process like other props *)
    let type_ = match pattern with
      | {ppat_desc = Ppat_constraint (_, type_)} -> Some type_
      | _ -> None
    in
    ( ( Labelled {txt = "ref"; loc = Location.none},
        None, pattern, txt, pattern.ppat_loc, type_ )
      :: args,
      newtypes, core_type )
  else
    (* React 18: ref is optional in forwardRef *)
    let type_ = match pattern with
      | {ppat_desc = Ppat_constraint (_, type_)} -> Some type_
      | _ -> None
    in
    ( ( Optional {txt = "ref"; loc = Location.none},
        None, pattern, txt, pattern.ppat_loc, type_ )
      :: args,
      newtypes, core_type )

3. Improve Error Messages

When using ref in React 18 mode:

Error: `ref` cannot be passed as a normal prop in React 18.

Options:
1. Upgrade to React 19:
   npm install react@19 react-dom@19

2. Use a custom ref callback prop:
   ~setButtonRef: option<ReactDOM.domRef => unit>=?

See: https://rescript-lang.org/docs/react/latest/forwarding-refs

Breaking Changes

None - This is an opt-in feature:

  • React 18 users: Existing behavior unchanged (ref still blocked)
  • React 19 users: ref automatically enabled
  • Existing React.forwardRef code: Continues to work (React 19 still supports it)

Benefits

  1. Full React 19 alignment - Use standard React patterns
  2. Documentation consistency - Official recommendations actually work
  3. Cleaner code - Reduced boilerplate
  4. Ecosystem unity - Matches JavaScript/TypeScript React
  5. Future-proof - Ready for eventual forwardRef removal

Example Use Cases

Focus Management

let buttonRef = React.useRef(Nullable.null)

React.useEffect0(() => {
  buttonRef.current->Nullable.toOption->Option.map(el => el->focus())->ignore
  None
})

<Button ref={buttonRef}>
  {React.string("Auto-focus")}
</Button>

Scroll Control

let errorButtonRef = React.useRef(Nullable.null)

// Scroll to error button
errorButtonRef.current
  ->Nullable.toOption
  ->Option.map(el => el->scrollIntoView({"behavior": "smooth"}))
  ->ignore

<Button ref={errorButtonRef} variant=Destructive>
  {React.string("Fix Error")}
</Button>

Third-party Libraries

let tooltipTargetRef = React.useRef(Nullable.null)

React.useEffect1(() => {
  // Integrate with Tippy.js, Popper.js, etc.
  tooltipTargetRef.current
    ->Nullable.toOption
    ->Option.map(el => Tippy.make(el, options))
    ->ignore
  None
}, [])

<Button ref={tooltipTargetRef}>
  {React.string("Hover Me")}
</Button>

Community Interest

  • React 19 released December 2024
  • @rescript/react 0.14.0 already added "Bindings for new React 19 APIs"
  • ReScript users reporting successful React 19 upgrades (forum discussion)
  • Growing need for full React 19 compatibility

Prior Work: PR #7420

ReScript 12.0.0-alpha.13 included PR #7420, which removed this error:

(* Removed in PR #7420 - Line 254-257 of jsx_v4.ml *)
| Pexp_fun {arg_label = Labelled {txt = "ref"} | Optional {txt = "ref"}} ->
  Jsx_common.raise_error ~loc:expr.pexp_loc
    "Ref cannot be passed as a normal prop. Please use `forwardRef` API \
     instead."

This was an important first step, but it only addressed the compiler error. The type system still doesn't properly handle ref as a prop, resulting in type mismatches when trying to use it.

What's Still Missing

While PR #7420 removed the error, developers still face:

  1. Type incompatibility: option<JsxDOM.domRef> doesn't match the expected JsxDOM.domRef type
  2. No prop forwarding: Can't simply pass ~ref through to child components
  3. Workaround required: Must still use ref callback pattern instead of standard React 19 pattern

This proposal completes the work started in PR #7420 by adding full type system support.

Supporting Evidence from Codebase

While analyzing the compiler source, I found this TODO comment in jsx_v4.ml (line 147):

(* TODO: Worth thinking how about "ref_" or "_ref" usages *)

This suggests the team has already been considering improvements to ref handling. This proposal offers a clean solution that aligns with React 19's direction rather than introducing workaround patterns like ref_.

Open Questions

  1. Version detection strategy: I've proposed three options above (A: explicit config, B: auto-detect, C: hybrid). My recommendation is Option C (Hybrid) for the best DX balance. What's the team's preference?

  2. Default behavior: Should the default be:

    • 18 (safe, backward compatible) ← my recommendation
    • "auto" (convenient, but requires package.json parsing)
    • 19 (forward-looking, but breaking for React 18 users)
  3. JSX version coupling: Should this feature be limited to JSX v4, or also support JSX v3?

  4. Migration guide: Would you like documentation showing how to migrate from forwardRef to ref prop?

  5. Error message format: Is the proposed error message helpful enough, or would you prefer different guidance?

References


Feedback welcome! Please let me know if this aligns with ReScript React's direction and if you have suggestions for the implementation approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions