Zaid-Ajaj / Feliz

A fresh retake of the React API in Fable and a collection of high-quality components to build React applications in F#, optimized for happiness
https://zaid-ajaj.github.io/Feliz/
MIT License
531 stars 77 forks source link

Component created with a forwardRef that has generic type parameters loses state #597

Closed Jacques2Marais closed 3 months ago

Jacques2Marais commented 4 months ago

I have two components, GenericInputWithForwardRef and GenericInputWithoutForwardRef. Both these components have generic type parameters in the F# function definition. The first component also uses React.forwardRef in its definition. Here is the setup:

let GenericInputWithForwardRef<'t> = React.forwardRef (
   fun ((), ref) ->
      let value, setValue = React.useState ""
      Html.input [
         prop.value value
         prop.onChange setValue
         prop.ref ref
      ]
)

let GenericInputWithoutForwardRef<'t> () =
   let value, setValue = React.useState ""
   Html.input [
      prop.value value
      prop.onChange setValue
   ]

I then use these components as following

let refO = React.useRef None

React.fragment [
  GenericInputWithForwardRef ((), refO)
  GenericInputWithoutForwardRef ()
]

The problem is, whenever I change the value of the second component, the first component unmounts and mounts again, thus losing its state and value. After some debugging, I also realized that I forgot to add the [<ReactComponent>] attribute above the two component definitions. But doing this causes another issue:

image

Looking at the generated code on line 652 of FormTesting.fs.js, I see the following:

const xs = [createElement(GenericInputWithForwardRef, {})([void 0, refO]), createElement(GenericInputWithoutForwardRef, null)];
return react.createElement(react.Fragment, {}, ...xs);

And the error seems to be right after the part createElement(GenericInputWithForwardRef, {}) (the first create element). The generated code for GenericInputWithForwardRef is as following

export function GenericInputWithForwardRef() {
    return React_forwardRef_3790D881((tupledArg) => {
        const ref = tupledArg[1];
        const patternInput = useFeliz_React__React_useState_Static_1505("");
        const value = patternInput[0];
        const setValue = patternInput[1];
        return createElement("input", {
            value: value,
            onChange: (ev) => {
                setValue(ev.target.value);
            },
            ref: ref,
        });
    });
}

The issue seems to be perhaps with Feliz' implementation of React.forwardRef? Thank you.

lukaszkrzywizna commented 3 months ago

Firstly, the ReactComponent attribute isn't necessary for React.forwardRef; this function inherently creates a component.

The issue lies in how Fable handles generics—it transforms them into functions, whereas typically, they might be fields. Consider the following examples:

let NonGeneric = React.forwardRef(fun (props: {| x: int |}, ref) -> Html.span "Hello Non Generic!")

let Generic<'t> = React.forwardRef(fun (props: {| x: 't |}, ref) -> Html.span "Hello Generic!")
// Const - OK, component is defined once
export const NonGeneric = React_forwardRef_3790D881((tupledArg) => {
    const props = tupledArg[0];
    const ref = tupledArg[1];
    return createElement("span", {
        children: ["Hello Non Generic!"],
    });
});

// Function - not OK, component is re-defined with every render
export function Generic() {
    return React_forwardRef_3790D881((tupledArg) => {
        const props = tupledArg[0];
        const ref = tupledArg[1];
        return createElement("span", {
            children: ["Hello Generic!"],
        });
    });
}

There are three solutions:

  1. Define a function that includes a predefined generic parameter:
    let StringSpecific = Generic<string>
// It's again a const defined once during module loading
export const StringSpecific = Generic();
  1. Rely on useCallback for optimization. This approach is somewhat intricate: you must create an additional component that yields your generic component. By wrapping this intermediary component with useCallback, it ensures that it is instantiated only once.

    [<ReactComponent>]
    let Generic<'t> x = x |> React.useCallback(React.forwardRef(fun (props: {| x: 't |}, ref) ->
    Html.span "Hello Non Generic!"))
    export function Generic(genericInputProps) {
    const x__1 = genericInputProps.x_1;
    const x_ = genericInputProps.x_0;
    const x = [x_, x__1];
    return useReact_useCallback_1CA17B65(React_forwardRef_3790D881((tupledArg) => {
        const props = tupledArg[0];
        const ref = tupledArg[1];
        return createElement("span", {
            children: ["Hello Non Generic!"],
        });
    }))(x);
    }
  2. [Prefered] Rename the ref prop to a different identifier. Given that React plans to eliminate the necessity for forwardRef (details available here), you can adapt by simply using a different name than ref. The React mechanism only searches for the prop name; no additional effort is required.

I hope this clarifies the core of the issue and allows us to close it. 😄

Jacques2Marais commented 3 months ago

Thank you @lukaszkrzywizna. I have indeed switched to using the ref prop under a different identifier. I will now close the issue.