Skip to content

Errors

When a view throws during render, React does not automatically replace only that view with an error message. Without an error boundary, React can unmount the entire root and leave the user with a blank page.

Wrap independently useful regions with h/error-boundary:

(ns app.todos
  (:require [re-frame.fresco :as h]))

(h/defview todo-page [_]
  [:main
   [todo-header {}]

   [h/error-boundary
    {:fallback [:p.oops "We couldn't show your todos."]}
    [todo-list {}]]

   [todo-footer {}]])

If todo-list throws while rendering, the fallback replaces that region. The header and footer stay mounted.

h/error-boundary is the component that catches. A normal defview boundary only defines an independently re-rendering view; it is not an error boundary.

Boundary options

An error boundary accepts three props:

Prop Shape Behaviour
:fallback Hiccup, or (fn [error] hiccup) Replaces the children after a caught failure
:reset-key Any value compared with = When it changes, clear the caught failure and remount the children
:on-error Event vector or plain function Run once for each caught failure

Fallbacks

A fallback may be static Hiccup:

{:fallback [:p.oops "This panel failed."]}

Or it may inspect the thrown value:

{:fallback
 (fn [error]
   [:div.oops
    [:p "This panel failed."]
    (when ^boolean goog.DEBUG [:pre (ex-message error)])])}

Show detailed messages in development and user-safe copy in production. The fallback renders under the same frame as the boundary, so event vectors in it dispatch normally.

Keep fallbacks simple. A fallback that reads the same broken state or performs heavy work can throw itself. That second failure is caught only by the next boundary above it.

Retry with :reset-key

A caught boundary stays in the failed state until its :reset-key changes. The change clears the failure and remounts the children from scratch.

Keep the key in app-db and increment it when the user presses Retry. The next section shows the full pattern.

Report with :on-error

An event vector receives the thrown error as its final argument:

:on-error [:todo/record-failure]
;; dispatched as [:todo/record-failure error]

The event dispatches into the boundary's frame. A plain function is called with the error instead and dispatches nothing.

:on-error runs once per caught failure. StrictMode may run the failing render more than once in development, but one React catch produces one report.

Nested boundaries and retry

The nearest error boundary above the throw handles it:

(ns app.todos
  (:require [re-frame.core :as rf]
            [re-frame.fresco :as h]))

(rf/reg-sub :todo.ui/list-attempt
  (fn [db _query]
    (:todo.ui/list-attempt db 0)))

(rf/reg-event :todo/retry-list
  (fn [{:keys [db]} _event]
    {:db (update db :todo.ui/list-attempt (fnil inc 0))}))

(rf/reg-event :todo/record-failure
  (fn [{:keys [db]} [_ error]]
    {:db (update db :todo/failures (fnil conj []) (ex-message error))}))

(h/defview todo-page [_]
  [:main
   [todo-header {}]

   [h/error-boundary
    {:fallback [:p.oops "We couldn't show your todos."]}

    [todo-filters {}]

    [h/error-boundary
     {:fallback
      (fn [_error]
        [:div.oops
         [:p "The list failed to render."]
         [:button {:on-click [:todo/retry-list]} "Try again"]])
      :reset-key (h/sub [:todo.ui/list-attempt])
      :on-error  [:todo/record-failure]}
     [todo-list {}]]]])

If todo-list throws, the inner boundary catches it and shows its fallback. The filters and header stay, and the outer boundary sees nothing.

The Try again button increments :todo.ui/list-attempt, so the reset key changes and the list mounts from scratch. If it throws again, the boundary catches the new failure.

A throw from todo-filters, which sits outside the inner boundary, reaches the outer boundary instead.

The cell below has a bug to trigger: todo-list upper-cases every title, and a todo with no title makes it throw. Add the untitled todo and the list's boundary shows its fallback while the buttons above it keep working; the failure count comes from :on-error. Try again remounts the list, which fails again while the bad todo is there. Remove it, then try again.

(require '[clojure.string :as str]
         '[re-frame.core :as rf]
         '[re-frame.fresco :as h])

(rf/reg-event :todo/initialise
  (fn [_ _]
    {:db {:todos {1 {:id 1 :title "Buy milk"}
                  2 {:id 2 :title "Walk the dog"}}}}))

(rf/reg-event :todo/add-untitled
  (fn [{:keys [db]} _]
    {:db (assoc-in db [:todos 3] {:id 3 :title nil})}))

(rf/reg-event :todo/remove-untitled
  (fn [{:keys [db]} _]
    {:db (update db :todos dissoc 3)}))

(rf/reg-sub :todo/all
  (fn [db _]
    (vec (sort-by :id (vals (:todos db))))))

(rf/reg-sub :todo.ui/list-attempt
  (fn [db _query]
    (:todo.ui/list-attempt db 0)))

(rf/reg-event :todo/retry-list
  (fn [{:keys [db]} _event]
    {:db (update db :todo.ui/list-attempt (fnil inc 0))}))

(rf/reg-event :todo/record-failure
  (fn [{:keys [db]} [_ error]]
    {:db (update db :todo/failures (fnil conj []) (ex-message error))}))

(rf/reg-sub :todo/failures (fn [db _] (:todo/failures db)))

(h/defview todo-list [_]
  [:ul
   (for [{:keys [id title]} (h/sub [:todo/all])]
     [:li {:key id} (str/upper-case title)])])

(h/defview todo-page [_]
  [:main
   [:p
    [:button {:on-click [:todo/add-untitled]} "Add an untitled todo"]
    " "
    [:button {:on-click [:todo/remove-untitled]} "Remove it"]]
   [h/error-boundary
    {:fallback
     (fn [_error]
       [:div.oops
        [:p "The list failed to render."]
        [:button {:on-click [:todo/retry-list]} "Try again"]])
     :reset-key (h/sub [:todo.ui/list-attempt])
     :on-error  [:todo/record-failure]}
    [todo-list {}]]
   [:p "Failures recorded: " (count (h/sub [:todo/failures]))]])

[h/frame-root {:id :app :initial-events [[:todo/initialise]]}
 [todo-page {}]]

What an error boundary catches

The boundary follows React's error-boundary rules.

Caught: throws during render and throws from lifecycle or effect work in the descendant React tree.

Not caught: work the browser invokes outside render, including event handlers, timers, and promise continuations.

A re-frame2 event handler runs in the event pipeline. If it throws, the pipeline reports :rf.error/handler-exception with the event and frame, and the runtime keeps going. No view fallback renders.

A raw JavaScript callback that throws reaches the browser's error channel. The error boundary does not see it, because nothing failed during React rendering.

Expected failures are state

Use app-db values for failures you can name in advance: a 404, invalid input, an unavailable resource, or an expected permission denial.

Do not throw to express ordinary control flow:

;; Don't do this: a missing todo is an expected state.
(h/defview todo-detail [{:keys [id]}]
  (let [todo (h/sub [:todo/by-id id])]
    (when (nil? todo)
      (throw (ex-info "todo missing" {:id id})))
    [:h2 (:title todo)]))

Render the case explicitly:

(h/defview todo-detail [{:keys [id]}]
  (if-let [todo (h/sub [:todo/by-id id])]
    [:h2 (:title todo)]
    [:p "That todo no longer exists."]))

The explicit version is easy to test, shows a precise message, and leaves the error boundary for failures you did not plan for. Loading and failed statuses for remote data are covered in Async resources.

Place boundaries at useful recovery regions

A single boundary around the root turns every failure into a whole-page fallback and may remove navigation along with the broken content. A boundary around every small view creates noise without useful recovery.

Place a boundary around a region the user can continue without: a panel, a tab body, a sidebar widget, or a route's main content inside a shell that stays usable. Ask what should stay available when this region fails, and put the boundary at the level that preserves it.

Troubleshooting

Symptom Cause Fix
One view throws and the whole page blanks No boundary caught the render failure, so React unmounted the root Wrap the independently recoverable region with h/error-boundary
An event-handler exception does not show the fallback Event handlers run in the re-frame2 pipeline, not descendant React render Inspect the :rf.error/handler-exception record; do not expect a view fallback
The console still shows the error although the fallback rendered React logs every caught render error by default Expected: the boundary caught it. Record it with :on-error
Fallback appears and never clears There is no :reset-key, or its value never changes Drive a generation value from app-db and change it on Retry
An intent in the fallback, or a vector :on-error, raises :rf.error/fresco-intent-outside-boundary No frame is mounted above the boundary, so there is nowhere to dispatch the event. A vector :on-error is checked on the boundary's first paint, not when it catches Mount the region under h/frame-root or h/frame-provider, or give :on-error a function, which needs no frame
The boundary raises :rf.error/fresco-boundary-unknown-prop A prop other than :fallback, :reset-key or :on-error, usually a misspelling such as :on-errors Fix the key; the boundary accepts only those three
The boundary raises :rf.error/fresco-boundary-bad-on-error :on-error is a bare keyword or another non-callable value Use an event vector such as [:todo/record-failure] or a function
A panel fallback throws and the larger page fallback appears The fallback itself failed and the next outer boundary caught it Keep fallbacks small and avoid re-reading the failed state
:on-error appears to fire twice in development Two distinct failures occurred; StrictMode alone still produces one report per catch Inspect the two error records and their causes
A server-render throw is not caught by the client boundary Server rendering uses the server error channel; a client error boundary cannot handle server execution Apply the surface's server policy and server error handling (SSR and hydration)

When not to use an error boundary

Do not use it:

  • for an expected failure such as a 404, a validation error, or an empty result;
  • around every small view that has no recovery of its own;
  • as loading UI. Pending data is state; render it explicitly.