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 #7420 removed the compilation error (source)
- ✅ ReScript official docs strongly recommend "passing ref as a prop" (docs)
- ✅ React 19 makes
ref as 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 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
- ✅ 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
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:
- Type incompatibility:
option<JsxDOM.domRef> doesn't match the expected JsxDOM.domRef type
- No prop forwarding: Can't simply pass
~ref through 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 forwardRef to ref prop?
-
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.
This issue is from rescript-lang/rescript#8104
Motivation
React 19 has deprecated
forwardRefand now allows passingrefas a regular prop:Current State in ReScript 12: PR #7420 removed the compiler error that blocked
refas a prop, but type support is incomplete:Note:
ReactDOM.domRefis an alias forJsxDOM.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 treatsrefas a special prop rather than allowing it as a regular prop type.This creates an incomplete implementation:
refas prop the standard patternCurrent Workaround
We currently need to use custom callback props:
This works but:
Proposal
Allow
refas a regular prop when React 19 is detected:Technical Approach
I've analyzed the actual codebase (latest
mainbranch) and identified the exact modification points needed.1. React Version Configuration
Current
jsx_common.mlconfig structure (line 4-9):Proposed addition:
Configuration Options
I've identified three possible approaches for React version detection:
Option A: Explicit
rescript.jsonConfiguration{ "jsx": { "version": 4, "module": "React", "reactVersion": 19 } }Ext_json_parseOption B: Auto-detect from
package.json"workspace:*""next","canary","latest""file:...","git+https://...""npm:react@19"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 1818/19→ Explicit override (for edge cases or preference)2. Modify JSX v4 Transform
Update
compiler/syntax/src/jsx_v4.mlat two key locations:Location 1 - ref prop type handling (line 142-164, specifically line 148-159):
Location 2 - forwardRef ref handling (line 316-331):
3. Improve Error Messages
When using
refin React 18 mode:Breaking Changes
None - This is an opt-in feature:
refstill blocked)refautomatically enabledReact.forwardRefcode: Continues to work (React 19 still supports it)Benefits
forwardRefremovalExample Use Cases
Focus Management
Scroll Control
Third-party Libraries
Community Interest
@rescript/react0.14.0 already added "Bindings for new React 19 APIs"Prior Work: PR #7420
ReScript 12.0.0-alpha.13 included PR #7420, which removed this error:
This was an important first step, but it only addressed the compiler error. The type system still doesn't properly handle
refas a prop, resulting in type mismatches when trying to use it.What's Still Missing
While PR #7420 removed the error, developers still face:
option<JsxDOM.domRef>doesn't match the expectedJsxDOM.domReftype~refthrough to child componentsThis 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
Feedback welcome! Please let me know if this aligns with ReScript React's direction and if you have suggestions for the implementation approach.