-
Notifications
You must be signed in to change notification settings - Fork 476
Description
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:
- ✅ PR Do not error on
refas prop #7420 removed the compilation error (source) - ✅ ReScript official docs strongly recommend "passing ref as a prop" (docs)
- ✅ React 19 makes
refas prop the standard pattern - ❌ 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 URLA 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 1818/19→ Explicit override (for edge cases or preference)- Omitted → Default to 18 (backward compatible)
| Pros | Cons |
|---|---|
| ✅ Best of both worlds | |
| ✅ 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_typeLocation 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 (
refstill blocked) - React 19 users:
refautomatically enabled - Existing
React.forwardRefcode: Continues to work (React 19 still supports it)
Benefits
- ✅ Full React 19 alignment - Use standard React patterns
- ✅ Documentation consistency - Official recommendations actually work
- ✅ Cleaner code - Reduced boilerplate
- ✅ Ecosystem unity - Matches JavaScript/TypeScript React
- ✅ Future-proof - Ready for eventual
forwardRefremoval
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/react0.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:
- Type incompatibility:
option<JsxDOM.domRef>doesn't match the expectedJsxDOM.domReftype - No prop forwarding: Can't simply pass
~refthrough to child components - 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
-
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?
-
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)
-
JSX version coupling: Should this feature be limited to JSX v4, or also support JSX v3?
-
Migration guide: Would you like documentation showing how to migrate from
forwardReftorefprop? -
Error message format: Is the proposed error message helpful enough, or would you prefer different guidance?
References
- React 19 Release Notes
- React forwardRef (deprecated in 19)
- ReScript React - Forwarding Refs
- ReScript Forum: React 19 Discussion
Feedback welcome! Please let me know if this aligns with ReScript React's direction and if you have suggestions for the implementation approach.