Building a list view that updates in real time
Build a list of posts that follows edits, creates and deletes, with sorting, filtering, loading and empty states.
A list of posts should change when anything about the posts changes: a new post, a renamed one, a deleted one, a post edited in another tab.
You'll need: the blog schema and the React setup (SeedProvider and SeedClientGate) from Reading and writing data, and its Post type.
Render each row with its own hook
useItems refetches only when the set of item IDs changes, so a list that reads post.title straight from its items shows the old title after a rename. The reactivity model explains why, and Adding reactivity in React what each hook subscribes to.
Use useItems for the IDs and give each row its own useItem, which re-renders when any of that item's properties change:
import { useItem, useItems } from '@seedprotocol/react'
import type { Post } from './types'
export function PostList() {
const { items } = useItems({ modelName: 'Post' })
return (
<ul>
{items.map((item) => (
<PostRow key={item.seedLocalId} seedLocalId={item.seedLocalId} />
))}
</ul>
)
}
function PostRow({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
if (!post) return null
return <li>{post.title || 'Untitled'}</li>
}If a row shows only one field, useItemProperty({ seedLocalId, propertyName: 'title' }) is a smaller subscription that ignores changes to the other fields.
useItem also picks up edits made in other tabs: in the browser, a loaded item reads its rows again when the change feed reports a write to them.
By default, useItems lists local drafts only, so a post drops out of the list once it's published. Pass includeEas: true to list drafts and published or synced posts together.
Show loading and empty states
isLoading is true until the client is ready and the first fetch finishes, so the usual pattern works:
const { items, isLoading, error } = useItems({ modelName: 'Post' })
if (isLoading) return <p>Loading…</p>
if (error) return <p role="alert">Could not load posts: {error.message}</p>
if (items.length === 0) return <p>No posts yet</p>items keeps its identity while the listed items and their publish times are unchanged, so you can use it as an effect or memo dependency.
Delete items without leaving ghosts
useDeleteItem soft-deletes an item, and useItems drops it from the list when its live query sees the change.
Deleting the last item leaves it on screen until the list remounts, because useItems ignores an empty result while it still holds items (useItems).
Invalidate Seed's item-list queries once the delete resolves, or drive the list from a live query as in the next section. The item-list query keys start with ['seed', 'items']:
import { useQueryClient } from '@tanstack/react-query'
import type { Item } from '@seedprotocol/sdk'
import { useDeleteItem } from '@seedprotocol/react'
import type { Post } from './types'
function DeleteButton({ post }: { post: Post }) {
const { deleteItem, isLoading } = useDeleteItem()
const queryClient = useQueryClient()
const handleDelete = async () => {
await deleteItem(post as Item<any>)
await queryClient.invalidateQueries({ queryKey: ['seed', 'items'] })
}
return (
<button disabled={isLoading} onClick={() => handleDelete().catch(() => {})}>
Delete
</button>
)
}useQueryClient returns the client that SeedProvider set up. This only helps for deletes made in this tab. A list driven by a live query also empties when the last post is deleted in another tab.
deleteItem rejects when the delete fails, for example when the user doesn't own the post, and also puts the error in error. Catch the promise and show error. See useDeleteItem.
Deleted posts are listed by useItems({ modelName: 'Post', deleted: true }), for a trash view. There's no restore API.
Sort and filter by current values
useItems sorts newest first and takes no other order. Sorting or filtering its result in JavaScript doesn't run again when a post is edited, so a list sorted by title wouldn't move a renamed post.
For an order or a filter that follows edits, read the IDs and the values you sort and filter on with a live query, and keep rendering rows with useItem. Use postIndexQuery from Write the query, saved as src/queries.ts: it returns each draft post's current title and published value, sorted by title.
Filter the rows in the component, as the complete example does. The live query re-runs on every save, so the filter sees current values.
Values from the database are text, so compare published with the string 'true', not true. To sort by a Number property such as wordCount, select it with another subquery, wrap that subquery in CAST(… AS REAL), and sort on the result, or convert with Number() before comparing in JavaScript. Sorted as text, '10' comes before '9'.
postIndexQuery is defined at module level because a SQL tag function created during render loops; see Memoise the query.
Complete example
A post list with a published filter, a search box, sorting by title, and delete buttons. It renders inside SeedClientGate.
import { useMemo, useState } from 'react'
import type { Item } from '@seedprotocol/sdk'
import { useDeleteItem, useItem, useLiveQuery } from '@seedprotocol/react'
import { postIndexQuery, type PostIndexRow } from './queries'
import type { Post } from './types'
export function PostListView() {
const [onlyPublished, setOnlyPublished] = useState(false)
const [search, setSearch] = useState('')
const rows = useLiveQuery<PostIndexRow>(postIndexQuery)
const visible = useMemo(
() =>
(rows ?? []).filter(
(row) =>
(!onlyPublished || row.published === 'true') &&
(!search || (row.title ?? '').toLowerCase().includes(search.toLowerCase())),
),
[rows, onlyPublished, search],
)
return (
<section>
<input
type="search"
placeholder="Search titles"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={onlyPublished}
onChange={(e) => setOnlyPublished(e.target.checked)}
/>
Published only
</label>
{rows === undefined ? (
<p>Loading…</p>
) : visible.length === 0 ? (
<p>{rows.length === 0 ? 'No posts yet' : 'No posts match'}</p>
) : (
<ol>
{visible.map((row) => (
<PostRow key={row.local_id} seedLocalId={row.local_id} />
))}
</ol>
)}
</section>
)
}
function PostRow({ seedLocalId }: { seedLocalId: string }) {
const { item } = useItem({ modelName: 'Post', seedLocalId })
const post = item as Post | undefined
const { deleteItem, isLoading, error } = useDeleteItem()
if (!post) return null
return (
<li>
{post.title || 'Untitled'}{' '}
<button disabled={isLoading} onClick={() => deleteItem(post as Item<any>).catch(() => {})}>
Delete
</button>
{error && <span role="alert"> {error.message}</span>}
</li>
)
}useLiveQuery waits for the client and returns undefined until the first result, so the loading state doesn't flash "No posts yet". Deleting the last post empties the list, because the live query's empty result is used as is.
Gotchas
A row shows a stale value. It reads the value from a useItems result. Give the row its own useItem or useItemProperty.
A published post disappears. useItems and the query above list drafts only. Pass includeEas: true to useItems, or remove the uid condition from the query.
if (post.published) is always true. Values read back as strings once saved, and 'false' is truthy. Compare with 'true', or use the asBoolean helper from What you get back.
Related
- Adding reactivity in React: what each hook subscribes to
- The reactivity model: why
useItemscompares IDs - Querying with liveQuery: live SQL, the tables and their caveats
- Items and useLiveQuery: hook reference
- Optimistic item editing: the editor that goes with this list
Querying with liveQuery
Choose between the item hooks and live SQL queries, write a live query that sorts and filters posts by their current values, and handle loading, errors and Node.
Editing items optimistically
Build a form that edits an item in place: instant local updates, saves that don't lose keystrokes, validation messages, read-only items, and edits arriving from other tabs.