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:
finally
form 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:
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!
componentDidMount()
?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 your 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.
To summarize:
with-let
act as :component-did-mount
, only running when to component first renders.finally
form acts as :component-will-unmount
, running when component is no longer rendered.:component-did-mount
and
:component-did-update
,
running each time the component re-renders.
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>
)
}