← Files WixARCHIVED FILE
skills/wix-headless-templates/blog/project/src/wix/blog/blog-feed-store.ts
6.39 KB · Oct 8, 2026 · 12:02 UTC
// The blog feed as a framework-free store — the logic behind useBlogFeed, usable from React
// (useBlogFeed wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and
// render), or as the specification for a port. State, actions, subscribe/getState, emit after every
// change — the same shape as storefront's shop-store.
//
// Category and tag filters are applied by Wix (fetchPosts), never to a page already in hand; the
// two are mutually exclusive — setting one clears the other. A changed filter starts a fresh cursor
// chain; a late response from a superseded query is dropped. Paging appends by cursor. Author
// bylines and counters ride on the posts themselves (fetchPosts joins them per page).
//
// SSR-friendly: seed with `initialPage` (and `initialCategories`/`initialTags`) and the first page
// renders at once with no client fetch. A category or tag page seeds a FILTERED first page: pass the
// filter it was fetched with as `initialCategoryId`/`initialTagId`, and the seed is trusted for
// exactly that filter. A SPA passes nothing and `start()` fetches. One store per mounted feed (a
// page can hold a feed and a "latest posts" rail): createBlogFeedStore().
//
// Error vs empty: a failed read leaves `posts: []` AND sets `error`. Render the empty state only
// when `error` is null — "No posts yet" over a network failure is a lie.
import { fetchPosts } from "./posts";
import { fetchBlogCategories, fetchBlogTags } from "./taxonomy";
import type { BlogCategory, BlogTag, PostPage, PostSummary } from "./types";
export interface BlogFeedStoreOptions {
initialPage?: PostPage;
/** The filter `initialPage` was fetched with (a category page); omit for the unfiltered feed. */
initialCategoryId?: string | null;
initialTagId?: string | null;
initialCategories?: BlogCategory[];
initialTags?: BlogTag[];
pageSize?: number;
}
/** Everything a feed surface renders from. Read it with getState() or through a subscription. */
export interface BlogFeedState {
/** null while the first load is in flight — render skeletons, not an empty state. */
posts: PostSummary[] | null;
categories: BlogCategory[];
tags: BlogTag[];
activeCategoryId: string | null;
activeTagId: string | null;
/** True when another page exists — render a "load more" control on it. */
hasMore: boolean;
loadingMore: boolean;
/** A failed read. While set, `posts` may be [] — that is not an empty blog. */
error: string | null;
}
export interface BlogFeedStore {
getState(): BlogFeedState;
subscribe(listener: () => void): () => void;
/** Fetch the taxonomy (unless seeded) and the first page (unless seeded for the active filter). Call once when mounted. */
start(): void;
/** Stop reacting; drop late responses. */
stop(): void;
/** Server-side filter; clears the tag filter. */
setActiveCategoryId(id: string | null): void;
/** Server-side filter; clears the category filter. */
setActiveTagId(id: string | null): void;
loadMore(): Promise<void>;
}
export function createBlogFeedStore({
initialPage,
initialCategoryId = null,
initialTagId = null,
initialCategories,
initialTags,
pageSize = 20,
}: BlogFeedStoreOptions = {}): BlogFeedStore {
let posts: PostSummary[] | null = initialPage?.posts ?? null;
let cursor: string | null = initialPage?.nextCursor ?? null;
let categories: BlogCategory[] = initialCategories ?? [];
let tags: BlogTag[] = initialTags ?? [];
let activeCategoryId: string | null = initialCategoryId;
let activeTagId: string | null = initialTagId;
let loadingMore = false;
let error: string | null = null;
let started = false;
let generation = 0; // bumped by every filter change and stop(); a response from an older generation is dropped
const listeners = new Set<() => void>();
let snapshot: BlogFeedState | null = null;
const emit = () => { snapshot = null; for (const fn of listeners) fn(); };
function getState(): BlogFeedState {
if (snapshot) return snapshot;
snapshot = { posts, categories, tags, activeCategoryId, activeTagId, hasMore: cursor !== null, loadingMore, error };
return snapshot;
}
// The seeded page answers exactly the filter it was fetched with — nothing else.
const seedMatches = () => Boolean(initialPage) && activeCategoryId === initialCategoryId && activeTagId === initialTagId;
// The first page for the current filter: the seed when it matches, a fetch otherwise.
function query(): void {
if (!started) return;
const id = ++generation;
loadingMore = false;
if (seedMatches()) {
posts = initialPage!.posts; cursor = initialPage!.nextCursor; error = null;
emit();
return;
}
posts = null; cursor = null; error = null;
emit();
fetchPosts({ limit: pageSize, categoryId: activeCategoryId, tagId: activeTagId })
.then((page) => {
if (generation !== id) return; // superseded — drop it
posts = page.posts; cursor = page.nextCursor;
emit();
})
.catch((e) => {
if (generation !== id) return;
posts = []; cursor = null;
error = e instanceof Error ? e.message : String(e);
emit();
});
}
return {
getState,
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
start() {
if (started) return;
started = true;
if (!initialCategories) fetchBlogCategories().then((c) => { if (started) { categories = c; emit(); } });
if (!initialTags) fetchBlogTags().then((t) => { if (started) { tags = t; emit(); } });
query();
},
stop() { started = false; generation++; },
setActiveCategoryId(id) {
if (id === activeCategoryId && activeTagId === null) return;
activeCategoryId = id; activeTagId = null;
query();
},
setActiveTagId(id) {
if (id === activeTagId && activeCategoryId === null) return;
activeTagId = id; activeCategoryId = null;
query();
},
async loadMore() {
if (!cursor || loadingMore) return;
const id = generation;
loadingMore = true; error = null; emit();
try {
const page = await fetchPosts({ limit: pageSize, cursor });
if (generation !== id) return;
posts = [...(posts ?? []), ...page.posts]; cursor = page.nextCursor;
} catch (e) {
if (generation !== id) return;
error = e instanceof Error ? e.message : String(e);
} finally {
if (generation === id) { loadingMore = false; emit(); }
}
},
};
}
SHA-256: 9048a413ae8d9722c056cca55c43411748ff7668e003b35784faa91c4c189162