japgolly / scalajs-react

Facebook's React on Scala.JS
https://japgolly.github.io/scalajs-react/
Apache License 2.0
1.64k stars 231 forks source link

Another way to define hook components #1088

Open nafg opened 8 months ago

nafg commented 8 months ago

I appreciate all the thought and effort that went into designing the new hooks API. It maximizes the admirable design goal of the library, to prevent runtime errors as much as possible.

However, after using it a bit I feel like it isn't the best ergonomically. Some observations that come to mind:

Also, #1087

In my own codebase I've started using the following approach instead. It's based on for comprehensions. It still prevents a lot of kinds of violations of the "Rule of Hooks", though perhaps not as many as the current API, but I find it a lot more ergonomic and readable.

Here is an example component:

      Hook.component[Props] { props =>
        for {
          error <- Hook.useSettable(Option.empty[String])
        } yield {
          val field =
            M.TextField(
              props.stateSnapshot.zoomStateL(editLens).toTagMod,
              _.label := props.label,
              _.fullWidth,
              _.variant.outlined,
              _.size.small,
              _.margin.dense,
              ^.onBlur ==> { (e: ReactEventFromInput) =>
                props.setFull(e.target.checkValidity()) >>
                  error.set(Some(e.target.validationMessage).filter(_.nonEmpty))
              },
              _.error := error.value.isDefined,
              _.helperText :=? error.value.map(vdomNodeFromString)
            )()(props.extraProps *)
          props.tooltip.toOption.foldRight[VdomElement](field)(M.Tooltip(_)(_))
        }
      }

Here is that component using the current API:

      ScalaFnComponent.withHooks[Props]
        .useState(Option.empty[String])
        .render { (props, error) =>
          val field =
            M.TextField(
              props.stateSnapshot.zoomStateL(editLens).toTagMod,
              _.label := props.label,
              _.fullWidth,
              _.variant.outlined,
              _.size.small,
              _.margin.dense,
              ^.onBlur ==> { (e: ReactEventFromInput) =>
                props.setFull(e.target.checkValidity()) >>
                  error.setState(Some(e.target.validationMessage).filter(_.nonEmpty))
              },
              _.error := error.value.isDefined,
              _.helperText :=? error.value.map(vdomNodeFromString)
            )()(props.extraProps *)
          props.tooltip.toOption.foldRight[VdomElement](field)(M.Tooltip(_)(_))
        }

Here is how it's defined:

object Hook {
  case class Thunk[+A] private[Hook](run: () => A) {
    def map[B](f: A => B): Thunk[B] = Thunk(() => f(run()))
    def flatMap[B](f: A => Thunk[B]): Thunk[B] = f(run())
  }

  implicit def custom[A](hook: CustomHook[Unit, A]): Thunk[A] =
    Thunk(() => hook.unsafeInit(()))

  def useSettable[A](initial: => A) = custom(chesednow.sjs.common.useSettable(initial))

  def useMemo[D: Reusability, A](deps: => D)(f: D => A): Thunk[Reusable[A]] =
    custom(Hooks.UseMemo(deps)(f))

  def component[P](f: P => Thunk[VdomNode])(implicit name: sourcecode.FullName) =
    ScalaFnComponent.withHooks[P]
      .render(f(_).run())
      .withDisplayName(name)

  def componentNamed[P](name: String)(f: P => Thunk[VdomNode]) =
    ScalaFnComponent.withHooks[P]
      .render(f(_).run())
      .withDisplayName(name)
}

Some notes: