Skip to content

Load more

Use an infinite resource when each new page should append to an article feed. The cache keeps the loaded pages and next cursor; the view reads one growing list and dispatches :rf.resource/load-more.

For numbered links that replace the current page, use Paginate a feed.

Register the feed

This endpoint returns {:items [...] :page-info {:next-cursor ...}}. Each item has a stable :slug and a :title. A nil cursor means the feed is complete. The resource is public and has the same response for every viewer; viewer-dependent feeds need a scope resolver.

;; cf. examples/capabilities/resources/infinite_feed/core.cljs
(ns app.feed
  (:require [re-frame.core :as rf]
            [re-frame.resources]
            [re-frame.http.managed]
            [re-frame.routing]))

(rf/reg-resource :feed/articles
  {:params-schema [:map]
   :scope :rf.scope/global
   :infinite true
   :page->items :items
   :next-page-param (fn [last-page _all-pages]
                      (get-in last-page [:page-info :next-cursor]))
   :tags (fn [_params _pages] #{[:article-feed]})}
  (fn [_params {:rf.resource/keys [page-param]}]
    {:request {:method :get
               :url "/api/article-feed"
               :params (cond-> {:limit 10}
                         page-param (assoc :cursor page-param))}
     :decode :json}))

:page->items extracts each page's article rows. :next-page-param computes the next cursor from the last reply. The runtime passes it to the next request as :rf.resource/page-param, initially nil.

The cursor is separate from the resource's params. Params identify a feed, such as a tag filter; a different filter creates another entry that starts at its first page. Loading another page extends the current entry.

Let the route load the first page

(rf/reg-route :article/feed
  {:resources [{:resource :feed/articles
                :params (fn [_route] {})
                :blocking? true}]}
  "/feed")

The route owns the feed until it leaves. Entry ensures only the first page; :blocking? waits for that page and never waits for future Load more clicks.

Render the list and the button

The combined :rf.resource/infinite-state subscription returns the merged :items and the loading state:

(rf/reg-view article-feed []
  (let [query {:resource :feed/articles :params {}}
        feed @(subscribe [:rf.resource/infinite-state query])]
    (cond
      (or (= :idle (:status feed)) (:loading? feed))
      [:p "Loading articles…"]

      (:error feed)
      [:div
       [:p "Could not load the feed."]
       [:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry"]]

      :else
      [:div
       (when (empty? (:items feed)) [:p "No articles."])
       (into [:ul] (for [article (:items feed)]
                     ^{:key (:slug article)} [:li (:title article)]))
       [:button {:disabled (or (:fetching? feed) (:fetching-next? feed))
                 :on-click #(dispatch [:rf.resource/refetch query])} "Refresh"]
       (when (:fetching? feed) [:p "Refreshing…"])
       (when (:refresh-error feed)
         [:p "Could not refresh; showing the loaded articles. "
          [:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry refresh"]])
       (when (:page-error feed) [:p "Could not load another page."])
       (when (:has-next-page? feed)
         [:button {:disabled (:fetching-next? feed)
                   :on-click #(dispatch [:rf.resource/load-more
                                         (assoc query :cause [:user :articles/load-more])])}
          (cond
            (:fetching-next? feed) "Loading…"
            (:page-error feed) "Retry load more"
            :else "Load more")])
       (when-not (:has-next-page? feed) [:p "All articles loaded."])])))

Render article-feed for :article/feed in the application's root view. A load-more keeps existing rows visible. The runtime ignores another load-more while one is in flight and sends no request once the cursor is nil. The command needs no owner: the route already keeps this entry alive.

The cell below runs the feed, route and view against three stub pages. Each Load more appends a page to the same entry, and the button gives way to "All articles loaded." once a page returns a nil cursor.

(require '[re-frame.core :as rf]
         '[re-frame.resources]
         '[re-frame.http.managed]
         '[re-frame.routing]
         '[re-frame.http.stub-transport :as stub-transport])

;; Three stub pages. A route is keyed by the URL the request goes out on, with
;; its :params as the query string.
(defn rows [& ns] (mapv (fn [n] {:slug (str "a" n) :title (str "Article " n)}) ns))
(stub-transport/install-transport! :feed
  (stub-transport/route-transport
    {[:get "/api/article-feed?limit=10"]           {:body {:items (rows 1 2 3) :page-info {:next-cursor "c2"}}}
     [:get "/api/article-feed?limit=10&cursor=c2"] {:body {:items (rows 4 5 6) :page-info {:next-cursor "c3"}}}
     [:get "/api/article-feed?limit=10&cursor=c3"] {:body {:items (rows 7 8)   :page-info {:next-cursor nil}}}}))

(rf/reg-resource :feed/articles
  {:params-schema [:map]
   :scope :rf.scope/global
   :infinite true
   :page->items :items
   :next-page-param (fn [last-page _all-pages]
                      (get-in last-page [:page-info :next-cursor]))
   :tags (fn [_params _pages] #{[:article-feed]})}
  (fn [_params {:rf.resource/keys [page-param]}]
    {:request {:method :get
               :url "/api/article-feed"
               :params (cond-> {:limit 10}
                         page-param (assoc :cursor page-param))}
     :decode :json}))

(rf/reg-route :article/feed
  {:resources [{:resource :feed/articles
                :params (fn [_route] {})
                :blocking? true}]}
  "/feed")

(rf/reg-view article-feed []
  (let [query {:resource :feed/articles :params {}}
        feed @(subscribe [:rf.resource/infinite-state query])]
    (cond
      (or (= :idle (:status feed)) (:loading? feed))
      [:p "Loading articles…"]

      (:error feed)
      [:div
       [:p "Could not load the feed."]
       [:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry"]]

      :else
      [:div
       (when (empty? (:items feed)) [:p "No articles."])
       (into [:ul] (for [article (:items feed)]
                     ^{:key (:slug article)} [:li (:title article)]))
       [:button {:disabled (or (:fetching? feed) (:fetching-next? feed))
                 :on-click #(dispatch [:rf.resource/refetch query])} "Refresh"]
       (when (:fetching? feed) [:p "Refreshing…"])
       (when (:refresh-error feed)
         [:p "Could not refresh; showing the loaded articles. "
          [:button {:on-click #(dispatch [:rf.resource/refetch query])} "Retry refresh"]])
       (when (:page-error feed) [:p "Could not load another page."])
       (when (:has-next-page? feed)
         [:button {:disabled (:fetching-next? feed)
                   :on-click #(dispatch [:rf.resource/load-more
                                         (assoc query :cause [:user :articles/load-more])])}
          (cond
            (:fetching-next? feed) "Loading…"
            (:page-error feed) "Retry load more"
            :else "Load more")])
       (when-not (:has-next-page? feed) [:p "All articles loaded."])])))

[rf/frame-root {:id :feed
                :initial-events [[:rf.route/navigate {:to :article/feed}]]}
 [article-feed]]

Recover from a failed page

Failure Field UI
First page fails before any data arrives :error Full error with refetch
Refresh of the first page fails :refresh-error Keep rows and offer refresh retry
A later load-more or refetch page fails :page-error Keep rows and offer the appropriate retry

A failed load-more retains its cursor, so the same button retries that page. A failed later-page refetch needs refetch instead. The two background error fields can coexist; render both. A successful page append or replacement clears them.

With a real endpoint, disable the network before clicking Load more. Verify that every loaded row stays visible, then reconnect and retry the missing page. The runnable example answers from a stub transport to demonstrate page appends without a backend.

Refresh the loaded feed

By default :rf.resource/refetch replaces only the first page and retains the tail. If a write can change all loaded pages, declare :refetch {:refetch-all-pages? true}. Each page is reread with its saved cursor; the server's cursor contract must support that.

Tag invalidation uses the same policy. A mutation with :invalidates (fn [_params _result] #{[:article-feed]}) in the feed's scope refreshes the owned feed. For changes affecting an item in the feed, invalidate the feed instead of patching its internal page vector.

The infinite-resource reference records windowed refetch, the complete subscription fields and supported options.

Troubleshooting

Symptom Cause Fix
:rf.error/infinite-missing-next-page-param No next-cursor function Register :next-page-param and return nil at the end
:rf.error/infinite-missing-page-accessor Pages are envelopes without an item accessor Set :page->items :items for this response shape
:rf.warning/resource-load-more-owner-ignored Load-more supplied another owner Keep the owner on the initial ensure or route
A filter change appends unrelated rows The filter is absent from params Put it in the resource's identity and route plan

Advanced

An IntersectionObserver can dispatch the same command instead of a button. Call rf/capture-frame during render. Keep that capture for the mount callback, which creates the observer, observes the sentinel and disconnects on unmount:

;; Call at mount with the frame capture made during render.
(defn load-more-observer [{:keys [dispatch]}]
  (js/IntersectionObserver.
   (fn [entries _observer]
     (when (.-isIntersecting (aget entries 0))
       (dispatch [:rf.resource/load-more
                  {:resource :feed/articles :params {}
                   :cause [:user :articles/scroll]}])))))

The browser calls the observer outside frame context. Its captured dispatch targets the correct frame; a bare rf/dispatch there raises :rf.error/no-frame-context.

Validate each page by putting a Malli schema in the request's :decode. Classification paths such as :sensitive [[:data :items :author-email]] apply to every page's rows. The reference records both forms. Under SSR, an eligible hydrated feed continues loading from its saved cursor.