This is a grab-bag document for useful hints and tips when building and maintaining React applications using helix.
Performance is critical for a lot of user interfaces. React provides tools to memoize calculations a number of different ways, including whole components.
NOTE: Memoization has its own cost! Do not memoize just because; profile, then optimize!
With class components, you can use the componentShouldUpdate
method to
tell React whether to call the render
method. With function components (as
created by defnc
), React provides the top-level memo
higher-order component
that you can wrap your component in.
One thing to note is that React's memo function allows you to pass in a custom comparator, which you can use to determine whether to re-render based on the old props and new props. This is not always required. By passing in no comparator, React will default to doing a referential identity check on each prop. Most often, this is what you want!
If you do want to customize the props comparator, the memo
function will
receive the JS props objects, not a props map like we expect to be passed to
our defnc
component.
Helix provides helix.core/memo
as a wrapper around React's memo
function
which handles the coercion to a props map just like defnc
does. In the cases
where you need to use a custom comparator, use that instead!
On the topic of Memoizing components above, we mentioned custom comparators. One thing we should avoid doing unless absolutely necessary is a deep comparison of objects. Here's an example:
(defnc my-component
{:wrap [(helix.core/memo =)]}
[{:keys [data]}]
(d/div (pr-str data)))
Looks simple and easy! But if our data
prop isn't identical to the previous,
=
will do a recursive walk of the structure until it bottoms out in a value
that is equal, or can't possibly be equal (different type, or a primitive like
string/number/etc. that aren't equal).
For a component that we're supposedly trying to optimize, this can end up adding a ton of extra work each render! Its worst case scenario is if the data is different but structurally similar, which in my experience tends to be true; the data we keep in state or returned by our servers doesn't tend to change drastically in shape render-to-render.
Instead, it's usually better to compare props using identical?
(which
React.memo
and helix.core/memo
do by default) and try and maintain
referential identity when data has not changed. For instance, if you're
computing data during render, wrap it in use-memo
to only re-compute it when
its dependent data changes. Same thing for event handlers and other functions
you pass in to components.
It can be tempting to add a custom comparator to ignore things like event
handlers and other functions passed in as props, however this can often lead to
subtle bugs later on where the handler needs to close over a new value or
changes in some other way and your component ignores the render triggered by
this change. Instead, use-callback
is your friend!
Helix's defnc
macro is the heart of the library. It also is going to be at the
heart of how you write your components. Keeping a consistent set of semantics
across a codebase you build is a key ingredient to maintainability and
onboarding.
Helix exposes experimental and optional features through feature flags. This is preferred over global state which might be used by component libraries, but it makes it cumbersome to add all of these feature flags across your whole app - or have some components which use different feature flags.
That's why it's slightly recommended to create your own defnc
macro which you
can use to build on and extend for your application. There, you can add any
feature flags you want, or even add other default functionality on top of helix
like hiccup parsing, certain higher order components, debug statements -
whatever your heart desires.
Creating a macro that wraps helix's is simple and easy. See below for an example which turns on the experimental "fast-refresh" feature for all components that use it.
(ns my-app.lib
(:require
[helix.core]))
(defmacro defnc [type & form-body]
(let [[docstring form-body] (if (string? (first form-body))
[(first form-body) (rest form-body)]
[nil form-body])
[fn-meta form-body] (if (map? (first form-body))
[(first form-body) (rest form-body)]
[nil form-body])
params (first form-body)
body (rest form-body)
opts-map? (map? (first body))
opts (cond-> (if opts-map?
(first body)
{})
(:wrap fn-meta) (assoc :wrap (:wrap fn-meta)))
;; feature flags to enable by default
default-opts {:helix/features {:fast-refresh true}}]
`(helix.core/defnc ~type
~@(when docstring [docstring])
~@(when fn-meta [fn-meta])
~params
;; we use `merge` here to allow indidivual consumers to override feature
;; flags in special cases
~(merge default-opts opts)
~@body)))
Components would then use my-app.lib/defnc
instead of helix.core/defnc
to
define components. It has the same API as helix, but with the :fast-refresh
feature enabled by default. Nice!
If you're using clj-kondo, you might need to add a configuration to ignore the
fact that my-app.lib/defnc
is not a known macro. You can do this by adding a
.clj-kondo/config.edn
file to your project with the following contents:
{:hooks {:analyze-call {my-app.lib/defnc clj-kondo.lilactown.helix/defnc}}}
Don't forget to update
my-app.lib
to the actual namespace you're using!