fable-compiler / fable-react

Fable bindings and helpers for React and React Native
MIT License
275 stars 67 forks source link

API for React Hooks #140

Closed alfonsogarciacaro closed 5 years ago

alfonsogarciacaro commented 5 years ago

Seems React Hooks will be shipping in stable form soon and the API doesn't seem to have changed much. We will have to add bindings for them soon and as usual we've two alternatives: a) match the native API as close as possible, or b) be a bit imaginative and try to make something more F# friendly. I've been playing with the two.

Matching native API

type Hooks =
    [<Import("useState", from="react")>]
    static member useState<'T> (initialState: 'T): 'T * ('T->unit) = jsNative

    [<Import("useEffect", from="react")>]
    static member useEffect<'T> (effect: unit -> (unit->unit) option, ?checkValues: obj[]): unit = jsNative

// Usage
type Props =
    { MessageFormat: string }

let MyComponent (props: Props) =
    let state, setState = Hooks.useState("")

    Hooks.useEffect(fun () ->
        printfn "New render: %s" state
        None)

    div [ Class "main-container" ]
        [ input [ Class "input"
                  Value state
                  OnChange (fun ev -> ev.target?value |> setState) ]
          span [ ]
            [ str (String.Format(props.MessageFormat, state)) ] ]

ofFunction MyComponent { MessageFormat = "Hello, {0}!" } []
|> mountById "app"

REPL Example

I tried to create two overloads for useEffect but using the effect: unit -> (unit->unit) signature conflicts with Fable's uncurrying optimization.

Let's be imaginative

For example to encourage creation of memo stateful components:

[<Import("*", "react")>]
[<Emit("""$0.memo(function (props) {
    const [state, useState] = $0.useState($1);
    return $2(props, state, useState);
})""")>]
let memoWithState<'P,'S> (initState: 'S) (f: 'P->'S->('S->unit)->ReactElement): ReactElementType<'P> = jsNative

type Props = { MessageFormat: string }

let MyComponent =
    memoWithState "" <| fun (props: Props) state setState ->
        div [ Class "main-container" ]
            [ input [ Class "input"
                      Value state
                      OnChange (fun ev -> ev.target?value |> setState) ]
              span [ ]
                [ str (String.Format(props.MessageFormat, state)) ] ]

ReactElementType.create MyComponent { MessageFormat = "Hello, {0}!" } []
|> mountById "app"

REPL Example

It should be possible to pass also the display name of the component but I need to make a small modification in the compiler for that (enabling passing literal strings to the Emit template).

What do you think? As always, having more opinionated helpers is a bit risky because we need extra documentation, we may not cover all the cases, etc. Also, it seems that there'll be more hooks coming and it's also possible to create custom hooks by combining them, so we may need to stick to the official API anyways. Something that concerns me in this case is how to enforce/recommend the rules of hooks.

cc @vbfox @MangelMaxime @Zaid-Ajaj

alfonsogarciacaro commented 5 years ago

Hmm, this may be another case when I get carried away by abusing Emit. At first I thought the rules of hooks would prevent normal function composition, but this seems to work too:

let memoWithState<'P,'S> displayName (initState: 'S) (f: 'P->'S->('S->unit)->ReactElement) =
    memoBuilder displayName <| fun (props: 'P) ->
        let state, setState = Hooks.useState(initState)
        f props state setState

// Usage
type Props = { MessageFormat: string }

let MyComponent =
    memoWithState "MyComponent" "" <| fun (props: Props) state setState ->
        div [ Class "main-container" ]
            [ input [ Class "input"
                      Value state
                      OnChange (fun ev -> ev.target?value |> setState) ]
              span [] [ str (String.Format(props.MessageFormat, state)) ] ]

MyComponent { MessageFormat = "Hello, {0}!" }
|> mountById "app"

So we may provide the close-to-the-metal bindings and then recommend how to combine them and/or provide helpers for the most common helpers (like memoWithState).

alfonsogarciacaro commented 5 years ago

Those rules of hooks are tricky. They says hooks shouldn't be called from a nested function but this seems to work.

let MyComponent =
    memoWithState "MyCom" "" <| fun (props: Props) state setState ->
        // This is already happening in a nested call
        Hooks.useEffect(fun () ->
            printfn "New render: %s" state
            None)
        div [ Class "main-container" ]
            [ input [ Class "input"
                      Value state
                      OnChange (fun ev -> ev.target?value |> setState) ]
              span [] [ str (String.Format(props.MessageFormat, state)) ] ]

According to the detailed explanation, apparently the problems arise when the hooks are not called consistently in each render, but that's not the case here. I guess that's why it's working fine 🤷‍♂️

MangelMaxime commented 5 years ago

I am always to provide an API as close as possible to the native implementation. And then if needed, provide helpers to make it easier for people to use in an F# friendly way.

This allows people to just use what works via the friendly helpers while allowing them to explore other solution or specific needs they have by using the native API.

About the usage of the hooks, I don't have much idea because I don't yet understand they fully and I am still learning the memo feature 😂.

One thing for sure is that I don't like using <| operators. I know that dsyme explains all the time that we should avoid them. So we should provide an API that's clean without <|

nojaf commented 5 years ago

Not sure if I'm on board with the Hooks type as Fable.Helpers thing.

I would stick closer to the original API.

import { useState } from 'react';

function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

Should that not be

open Fable.React

let Example () =
    let [count, setCount] = useState(0);
    div [ ]
    [ p [ ]
        [ sprintf "You clicked %d times" count |> str ]
      button [ OnClick (fun _ -> setCount(count + 1))
        [ str "setCount(count + 1)}>
        Click me" ] ]
alfonsogarciacaro commented 5 years ago

@nojaf Unfortunately the syntax let [count, setCount] = useState(0) is not possible:

Given that Fable compiles tuples as JS arrays, this is a great opportunity to use a tuple to type the return value of useState.

nojaf commented 5 years ago

Ok, in my sample I used a list but I meant a tuple. I would use Option type to represent optional argument instead of optional arguments but that is personal preference.

jarlestabell commented 5 years ago

Given that Fable compiles tuples as JS arrays, this is a great opportunity to use a tuple to type the return value of useState.

Yes, a tuple for the return value of useState would be great. :)

mvsmal commented 5 years ago

Hi guys, any decision/progress on this?

alfonsogarciacaro commented 5 years ago

An alpha version of Fable.React 5 with basic support for React hooks is already pushed: https://github.com/fable-compiler/fable-react/blob/dd4473088012ec6875420e4ea67f546c113bb855/src/Fable.React.Helpers.fs#L42-L49

However, this release depends on updates of several other packages and I need to make some testing and write a post explaining about the updates. Hopefully it'll happen this week. If you can't wait, as the binding is just a few lines, you can use directly in your project :)

jannesiera commented 5 years ago

I'm happily using react hooks with Fable.React 5. However, useReducer seems to be missing. What's the reason for this?

MangelMaxime commented 5 years ago

Probably an omission as it's looks similar to what Elmish does for us.

We would accept a PR for adding it :)

nojaf commented 5 years ago

I use this in my own projects:

type ReduceFn<'state,'msg> = ('state -> 'msg -> 'state)  
type Dispatch<'msg> ='msg -> unit  
let useReducer<'state,'msg> (reducer: ReduceFn<'state,'msg>) (initialState:'state) : ('state * Dispatch<'msg>)  = import "useReducer" "react"