When I first started using Reagent, I was surprised to see the use of React class components. Whenever needing to hook
into the component lifecycle for things like add and removing event listeners, we were using componentDidMount() and
componentWillUnmount(). Hooks and function components have been a part of React since 2018, and the React developers
themselves have all but deprecated class components when they launched
the new React documentation website in 2023. Documentation on how to use class components in React
is now under the "Legacy API" section of the website. Modern React is functional and immutable, yet when writing React
in the functional and immutable realm of ClojureScript, we are using Object-Oriented class components and mutating
atoms.
In my crusade to abolish class-components, I had started researching the possibility of calling React hooks like
useEffect() directly in my Reagent applications, which Reagent supports using the :f> symbol when rendering
components (see this link for details).
Then, deeper
down in the Reagent documentation, I found with-let. This Reagent macro looks like Clojure's let, with two main
differences:
- The bindings are only evaluated once when the component mounts and not on subsequent re-renders, and
- It provides us with a
finallyform at the bottom of the component to house any cleanup functions when the component unmounts.
This macro can be effectively used to achieve two things:
- House component-level internal state without using inner functions
- Access the component lifecycle without writing class components or the
useEffect()hook.
Let's take a deeper look.
It's very common in Reagent to use an inner function for rendering a component when dealing with internal state. To borrow an example from Reagent's documentation:
(defn timer-component []
(let [seconds-elapsed (r/atom 0)]
(fn []
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed])))
This creates the seconds-elapsed atom only when the component first mounts, and the code in the inner function run on
every re-render. Without the inner function, the atom would be re-initialized to 0 on every re-render, and the timer
wouldn't work! However, we can leverage with-let to only evaluate the binding when the component mounts and eliminate
the need for the inner function:
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)]
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed]))
with-let also provides us with a finally form that runs when the component is no longer rendered. This is useful
when adding event listeners that need to be removed when the component no longer exists on the page. For instance, say
we have a button that renders a div container, and we want to close either when the button is clicked or when we
simply click outside the container. With classes, that looks like this:
(defn open-close-class-component []
(let [open? (r/atom false)
handler (partial on-click open?)]
(r/create-class
{:component-did-mount #(wjs/add-doc-listener "click" handler)
:component-will-unmount #(wjs/remove-doc-listener "click" handler)
:reagent-render
(fn []
[:<>
[:button "Click Me"]
(when @open? [:div "Look at me! I'm open!"])])})))
I don't know about you, but seeing this kind of Object-Oriented React in my ClojureScript makes me cringe. Instead, we can do this:
(defn open-close-class-component []
(r/with-let [open? (r/atom false)
handler (partial on-click open?)
_ (wjs/add-doc-listener "click" handler)]
[:<>
[:button "Click Me"]
(when @open? [:div "Look at me! I'm open!"])])
(finally (wjs/remove-doc-listener "click" handler)))
This has internal state and adds and removes event listeners without internal functions or clunky class components. Now that's clean!
Let's re-visit our timer example for a minute.
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)]
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed]))
For those used to thinking in terms of React class components, this might look strange. The side-effect-inducing
js/setTimeout appears to be being called from inside the render function, like this:
(defn timer-component []
(let [seconds-elapsed (r/atom 0)]
(r/create-class
{:reagent-render
(fn []
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed])})))
; NOT what is actually happening!
This, of course, is a huge violation of the best practice of render functions always being pure functions. Having side effects inside the render function can make applications unpredictable and hard to debug.
However, that is not actually what is happening in the first example. In Reagent (and React function components, too),
what is returned from the function is what is rendered. Remember that ClojureScript has implicit returns, so whatever
the last form in the function is, that is what gets rendered. js/setTimeout is never returned from the function, so
it's not polluting the renderer. The class component equivalent would be this:
(defn timer-component []
(let [seconds-elapsed (r/atom 0)]
(r/create-class
{:component-did-mount (fn [] (js/setTimeout #(swap! seconds-elapsed inc) 1000))
:component-did-update (fn [] (js/setTimeout #(swap! seconds-elapsed inc) 1000))
:reagent-render (fn [] [:div "Seconds Elapsed: " @seconds-elapsed])})))
As a rule of thumb, once our hiccup starts, there should be no side-effect-inducing code unless triggered by user events like click handlers.
Notice that js/setTimout is called both in :component-did-mount and :component-did-render, but is only called once
in the example that doesn't use class components. That's because the body of the function that's before the return value
is evaluated everytime the component renders, so it functions as both :component-did-mount and
:component-did-update. We can move it into the with-let to make sure it only runs once, getting the same result as
only using :component-did-mount. If we want it to act like :component-did-update and run on all subsequent
re-renders but not on the initial render, a simple conditional check against the internal state suffices:
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)
inc-timer (fn [] (js/setTimeout #(swap! seconds-elapsed inc) 1000))]
(when (> @seconds-elapsed 0) (inc-timer))
[:div "Seconds Elapsed: " @seconds-elapsed
[:button {:on-click inc-timer} "Start Timer"]]))
Now the timer will not start when it first mounts, but it will start when the user clicks the button and then will continue to run on each subsequent re-render until the component unmounts.
In some instances, we may need the component to wait until it's fully rendered before executing a function. For
example, the browser can't scroll to a component that isn't yet on the page. While bindings in a with-let are only
evaluated on initial mount, with-let does NOT wait for component render first. If we wanted a component to be
scrolled to the center of the screen when it's opened, the previous examples would not be able to achieve that.
In these instances, it may be tempting to switch back to the old class component syntax or reach for the useEffect
hook, since :component-did-mount, :component-did-update, and js/React.useEffect all wait for component render, but
Reagent gives us a more elegant solution: after-render.
When used in a with-let, the callback function provided to after-render will be called on initial mount:
(defn scroll-component []
(r/with-let [ref (atom nil)
_ (r/after-render #(scroll-to-center! @ref))]
[:div {:ref #(reset! ref %)} "I scroll when I first render!"]))
When used before the hiccup, the callback function will be called on initial mount and subsequent re-renders:
(defn scroll-component []
(r/with-let [ref (atom nil)]
(r/after-render #(scroll-to-center! @ref))
[:div {:ref #(reset! ref %)} "I scroll on every render!"]))
There are some scenarios where we might want to get even more specific with when the callback function happens though. For example, there may be a page-level state variable tracking which component should be scrolled to, and the scroll function should only be called on the correct component. This function would, of course, need to be called on a different component if that value changes. For that, we could do something like this:
(defn scroll-component [entity]
(r/with-let [ref (atom nil)]
(when (= @id-to-scroll-to (:id entity))
(r/after-render #(scroll-to-center! @ref)))
[:div {:ref #(reset! ref %)} "I scroll on every render!"]))
This will cause the component to re-render any time a swap! or reset! happens on id-to-scroll-to, though. If we
want to mitigate that, we could try moving the conditional check into the callback:
(defn scroll-component [entity]
(r/with-let [ref (atom nil)]
(r/after-render #(when (= @id-to-scroll-to (:id entity))
(scroll-to-center! @ref)))
[:div {:ref #(reset! ref %)} "I scroll on every render!"]))
Now we've stopped the unnecessary re-render, but there's nothing to tell the component to re-run the callback function
if the value of id-to-scroll-to changes. We could try using something like a Reagent track to try to force the
component to re-render if @id-to-scroll-to is its own ID, but that could start getting messy and puts us back to
having an unnecessary re-render when all we want is the callback to be invoked.
This is also a perfect example of when interoping with React's useEffect hook keeps things cleaner. useEffect take a
dependency array as a second argument. React will cache values in the dependency array, and if any of them change, it
will invoke the callback function. useEffect will also be invoked on initial render.
(defn scroll-component [entity]
(r/with-let [ref (atom nil)]
(js/React.useEffect #(when (= @id-to-scroll-to (:id entity))
(scroll-to-center! @ref))
(array @id-to-scroll-to))
[:div {:ref #(reset! ref %)} "I scroll on every render!"]))
Now, the callback function is invoked when we want it to be without introducing extra re-renders. It's important to not that the dependency array must be a JavaScript array, not a Clojure list or vector.
To summarize:
- The let bindings in
with-letact as:component-did-mount, only running when the component first renders. - The
finallyform acts as:component-will-unmount, running when component is no longer rendered. - The function body between the bindings and the returned hiccup acts as
:component-did-mountand:component-did-update, running each time the component re-renders.- Use a conditional check against some internal state to stop it from running on initial mount but still run on each subsequent re-render.
- Make sure all side-effect-inducing code runs before the returned hiccup starts, not inside of it.
- This makes sure that the function remains pure and does not pollute the renderer with unpredictable side effects.
- When it's necessary to wait until render has completed, use
after-render. For more complex use cases, interop withuseEffectto leverage the dependency array.
Thanks to with-let, we no longer need to use class components in Reagent to get access to the component lifecycle, nor
do we need inner functions when dealing with internal state.
For those more familiar with React function components rather than class components, or those simply curious about function components, I've decided to show what these same examples would look like in React function components.
Reagent example:
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)]
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed]))
JavaScript React example:
export default function timerComponent() {
const [secondsElapsed, setSecondsElapsed] = useState(0);
useEffect(() => {
setTimeout(setSecondsElapsed(secondsElapsed + 1), 1000);
// dependency array tells React to run useEffect()
// everytime secondsElapsed changes
}, [secondsElapsed])
return (
<div>
`Seconds Elapsed: ${secondsElapsed}`
</div>
)
}
Reagent example:
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)
_ (js/setTimeout #(swap! seconds-elapsed inc) 1000)]
[:div "Seconds Elapsed: " @seconds-elapsed]))
JavaScript React example:
export default function timerComponent() {
const [secondsElapsed, setSecondsElapsed] = useState(0);
useEffect(() => {
setTimeout(setSecondsElapsed(secondsElapsed + 1), 1000);
// empty dependency array tells React to run
// useEffect() only on initial mount
}, [])
return (
<div>
`Seconds Elapsed: ${secondsElapsed}`
</div>
)
}
Reagent example:
(defn timer-component []
(r/with-let [seconds-elapsed (r/atom 0)
inc-timer (fn [] (js/setTimeout #(swap! seconds-elapsed inc) 1000))]
(when (> @seconds-elapsed 0) (inc-timer))
[:div "Seconds Elapsed: " @seconds-elapsed
[:button {:on-click inc-timer} "Start Timer"]]))
JavaScript React example:
export default function timerComponent() {
const [secondsElapsed, setSecondsElapsed] = useState(0);
const incTimer = () => {
setTimeout(setSecondsElapsed(secondsElapsed + 1), 1000);
}
useEffect(() => {
// conditional check prevents running on initial mount
if (secondsElapsed > 0) incTimer();
// dependency array tells React to run useEffect()
// everytime secondsElapsed changes
}, [secondsElapsed])
return (
<div>
`Seconds Elapsed: ${secondsElapsed}`
<button onClick={incTimer}>Start Timer</button>
</div>
)
}