lilac.town

Notes & opinions of Will Acton

Towards Helix 1.0

Posted at — Jan 3, 2021

In late 2018 I decided to fork my own library hx to make what I felt were some breaking changes. In my mind, it was a complete change of course: hx had been focused on parsing hiccup into React elements, and had included some utilities for defining components and using hooks as a convenience. Helix, the new library, was to focus on defining components and using hooks in a simple, fast, and idiomatic way to ClojureScript, and eschew hiccup or other special syntax that imposed more runtime cost than I found desirable.

Now in 2021, I am looking towards when I can call helix done. Not because I am sick of it, but because I believe that this leads to the most sustainable path as both a maintainer and user of software. I want to take the learnings from using helix in production over the last year and turn that into the highest quality library that people can use as a foundation for building UIs. Helix’s goal remains the same in 2021 as in 2018: provide a simple, easy to use API for React in ClojureScript.

This blog post serves as a place for me to write down all of my hopes and dreams for what helix 1.0 would look like. This is not a formal road map per se, but a snapshot of the things that I would like to add and change in helix before I call it done. I hope that it generates some discussion, at least.

Of course, there will always be changes and additions to ReactJS that need support. My hope is that those changes will either be opaque from an API perspective, or can be provided by another library (e.g. server-side components).

Native element creation macros

One of the early mistakes that helix made in it’s API was trying to automagically handle converting prop keys from Clojure idioms, like :on-click, to JS "onClick". This was necessary work for the DOM macros in helix.dom, and I naively assumed that I could generalize this to handle any “native” React component that used JS idioms. Hence, I provided the out of the box ability to pass in any keyword, string or annotate a symbol with metadata {:native true}, and the $ macro would do this conversion for you.

As it turns out, idioms are hard to capture as a collection of programmatic rules, and humans do not always apply them consistently. This meant that there were a number of cases where this “native” conversion did the wrong thing, and as a user you were left with mysterious error messages and, once you got to the root of it, a question of how to address it in Helix’s own idioms.

For instance, the style prop in react-dom should always be a camelCased JS object. But in React Native, the style prop can also be a JS array of objects, or an opaque StyleSheet type.

Another example is react-three-fiber which has its own idioms and special props which should (preferably) be converted to JS native types. Helix’s “native” conversion doesn’t handle this well at all.

Even the common scenario of using a 3rd party component in a react-dom application isn’t well served by annotating it with :native, as many implement JS idioms inconsistently.

The solution to this problem in helix 1.0 could be to provide specific macros for creating “native” elements in common platforms, e.g. helix.dom/$d, and removing the special handling of keyword, string and :native annotated elements passed to helix.core/$. This way, you always know exactly what $ is doing: taking your Clojure map as written and shallowly converting it into a JS object.

If you are using helix.dom to create DOM elements, e.g. helix.dom/div and helix.dom/button, and using no other “native” components in your code, then this change would not impact you at all as the helix.dom element macros would be implemented using the new platform-specific macro, which would have the same behavior as the “native” conversion in 0.0.13.

If you’re using the :native annotation in your react-dom application today, then a future version of helix (before 1.0) would emit a compiler warning that informs you where it is being used and how to fix it using the new $d macro.

If you’re using helix with something other than react-dom, e.g. react-native or react-three-fiber, then to solve the compiler warning may involve creating your own convenience macro, like $rn and $3f, to create elements out of React Native and three-fiber components. My hope is that we can collaborate and solve this as a community, as the current state is confusing as it is for newcomers.

Global build toggles for feature flags

This is not a breaking change, but certainly a welcome one. Helix currently allows users to enable feature flags locally in each component in the map of options after the args list of a component:

(defnc my-component
  [props]
  {:helix/features {:fast-refresh true}})

This allows library and application authors to control which features are used in their code without stepping on each others toes.

The downside to this is that it is quite tedious to write. The solution proposed by helix’s documentation is to create your own defnc macro, but this is onerous for application developers who have already adopted helix without creating a macro and now want to toggle a specific feature flag for their application.

In helix 1.0, you wouldl be able to toggle most feature flags globally at build time in order to allow this. It would still be suggested that for anyone authoring a library that depends on helix to create a custom macro if you want to rely on any feature flags that have runtime impact.

defhook static analysis and deps inference

Another addition: helix currently has a group of macros in the helix.hooks namespace which paper over some of the short comings of React’s API. Notably, for hooks like use-memo and use-callback, the macros provide dependency inference so that they can provide React with the values that should cause the values to be recomputed.

The goal of the defhook macro is to allow authors of custom hooks to take advantage of this same static analysis in a light-weight way. For example:

(defhook use-interval
  [^:deps dependencies f interval]
  (helix.hooks/use-effect
    dependencies
    (let [interval (js/setInterval f interval)]
      #(js/clearInterval interval))))


;; using deps inference
(let [[user-id set-user-id] (helix.hooks/use-state "2")]
  (use-interval
    :auto-deps
    (fn []
      (fetch-notifications {:user/id user-id}))
    100))

;; is the same as
(let [[user-id set-user-id] (helix.hooks/use-state "2")]
  (use-interval
    [fetch-notifications user-id]
    (fn []
      (fetch-notifications {:user/id user-id}))
    100))

This could greatly reduce the amount of defensive memoization necessary to ensure that code fires or recomputes only when we want to.

This would be opt-in and would be implemented as code transformation inside of components defined with helix’s defnc macro and custom hooks defined with defhook.

I would also like to explore alternatives to the explicit :auto-deps keyword, either eliding the dependency vector completely or some cuter way of annotating that dependencies should be inferred.

Define helix.hooks using defhook

The cute and clever wrapping of the body of use-effect and use-memo inside a function for you is less useful than I had hoped. It actively fights against some attempts at DRY, like:

;; `f` returns the function that should be run by the memo
(hooks/use-memo
  [foo bar]
  (f foo bar))

;; instead we must lexically bind the value and call it in the body
(let [fn-to-run (f foo bar)]
  (hooks/use-memo
    [foo bar]
    (fn-to-run)))

One can see as well that this prevents static analysis of the dependent values used by fn-to-run.

The 1.0 solution would be to define helix’s fundamental hooks without macros, using defhook - or at least, requiring that you pass in a 0-arity function instead of wrapping the body in one automatically for you - and moving them into helix.core. So the above example could be rewritten as:

(helix.core/use-memo
  [foo bar]
  (f foo bar))

;; normal usage without the factory function `f`
(helix.core/use-memo
  [foo bar]
  #(+ foo bar))

An earlier version can introduce these new core hooks and deprecate helix.hooks so that helix 1.0 could remove helix.hooks.

Conclusion

The most joyful part of working on helix has been how many people have contributed to it by adding features, fixing bugs, creating documentation and discussing various tricks and tradeoffs. It has been adopted by a number of professional ClojureScript groups as the fundamental library for building UI, including my own employer. The library is better and I am wiser because of it.

Let me know what you think of these ideas! Reach out to me on #helix in Clojurians slack or email me.