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 canned 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.test-support :as http-test-support])
;; Three canned pages. A stub matches the URL without :params, so this cell
;; puts the cursor in the URL itself.
(defn rows [& ns] (mapv (fn [n] {:slug (str "a" n) :title (str "Article " n)}) ns))
(http-test-support/install-managed-request-stubs!
{[:get "/api/article-feed"] {:reply {:ok {:items (rows 1 2 3) :page-info {:next-cursor "c2"}}}}
[:get "/api/article-feed?cursor=c2"] {:reply {:ok {:items (rows 4 5 6) :page-info {:next-cursor "c3"}}}}
[:get "/api/article-feed?cursor=c3"] {:reply {:ok {: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 (cond-> "/api/article-feed"
page-param (str "?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}]]
:fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[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 uses canned responses 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.