Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,8 @@ Without first-class support, this is three `useQuery`s and a client-side combine

```ts
const me = useCurrentUser()
const { data: teammates } = useQuery(q.people.where({ teamId: me.teamId, status: 'active' }))
const { data: issues } = useQuery(q.issues.where({ severity: 'critical', state: 'open' }))
const teammates = useQuery(q.people.where({ teamId: me.teamId, status: 'active' }))
const issues = useQuery(q.issues.where({ severity: 'critical', state: 'open' }))
const teammateIds = new Set(teammates.map(t => t.id))
const filtered = issues.filter(i => teammateIds.has(i.assigneeId))
```
Expand Down Expand Up @@ -458,14 +458,14 @@ Backend/manual responsibilities:

Figbird's strategic read hook has one contract: `useQuery` returns data only for the exact query key
passed in the current render. If that key is cold, it suspends. If that same key already has data,
refetches keep returning that data with `isFetching: true`. The common product path should not
contain an `isLoading` branch. `<Suspense>` and `<ErrorBoundary>` own loading and first-read errors
because that is where they compose properly with the rest of the React tree.
refetches keep returning that data while `useQueryResult` reports `isFetching: true`. The common
product path should not contain an `isLoading` branch. `<Suspense>` and `<ErrorBoundary>` own loading
and first-read errors because that is where they compose properly with the rest of the React tree.

The explicit tagged-union mode exists as an option on the same hook — `useQuery(query,
{ suspense: false })` returns `{ status, data, error, isFetching, refetch }` and never suspends or
throws. It is the right tool for components that render their own inline loading/error UI, but it is
not the north-star: documentation and product code lead with Suspense.
`useQueryResult(query, { suspense: false })` returns the explicit
`{ status, data, error, isFetching, refetch }` union and never suspends or throws. The default
`useQueryResult(query)` keeps Suspense but exposes metadata and controls. Both hooks call the same
subscription implementation; their names make the return contract visible at the call site.

### Cache Entries As Tagged Unions

Expand Down Expand Up @@ -552,7 +552,7 @@ function changeFilter(next: string) {
startTransition(() => setFilter(next))
}

const { data } = useQuery(peopleList, { filter })
const data = useQuery(peopleList, { filter })
```

If `{ filter: next }` is cold and suspends, React can keep the previous committed render on screen:
Expand All @@ -564,7 +564,7 @@ For text input, split urgent input state from deferred query state:
```ts
const [draftSearch, setDraftSearch] = useState('')
const search = useDeferredValue(draftSearch)
const { data } = useQuery(peopleSearch, { search })
const data = useQuery(peopleSearch, { search })
```

The input can show `draftSearch` immediately. The results are explicitly for `search`. If the UI
Expand All @@ -574,13 +574,13 @@ The core API should work with plain `useQuery`, route preparation, `startTransit
`useDeferredValue`, and keyed Suspense boundaries. Do not add a Figbird-specific deferred-query
hook until repeated product code proves that the React primitives are too verbose.

### Why There Is No Second Hook
### Data And Result Hooks

A non-Suspense `{ status, data, error }` shape has to exist because some components legitimately
own their loading/error rendering. But it must not become a second mental model — so it is an
_option_ on the one hook (`{ suspense: false }`), not a separately named hook, and both modes run
the same query machinery underneath. The legacy `useFind` / `useGet` shims exist for older
codebases only; they are deprecated and also call into the same machinery.
Most components need query data and nothing else, so `useQuery` returns that data directly.
Components that own background state, manual refetching, pagination controls, or inline
loading/error rendering use `useQueryResult`. The result hook defaults to Suspense and accepts
`{ suspense: false }` for the tagged union. The names differ, but both projections run through one
subscription and Suspense implementation. The legacy `useFind` / `useGet` shims remain deprecated.

## Mutations And Optimism

Expand Down Expand Up @@ -766,7 +766,7 @@ const issueDetail = defineQuery(({ id }: { id: number }) =>
const prepared = prepare(issueDetail, { id: 42 })

// Inside the component:
const { data } = useQuery(issueDetail, { id: 42 })
const data = useQuery(issueDetail, { id: 42 })
```

Properties:
Expand Down Expand Up @@ -1660,7 +1660,7 @@ const myTeamCriticalIssues = figbird.deriveQuery(
)

// Component:
const { data } = useQuery(myTeamCriticalIssues, { currentUserId })
const data = useQuery(myTeamCriticalIssues, { currentUserId })
```

Properties:
Expand Down Expand Up @@ -1778,7 +1778,7 @@ There is no way to ask "how many open issues?" without fetching rows — the onl
tab counters, and dashboard tiles all want a number, not a window.

```ts
const { data: count } = useQuery(q.issues.where({ status: 'open' }).count()) // number
const count = useQuery(q.issues.where({ status: 'open' }).count()) // number
```

Transport: `$limit: 0` plus the find meta's `total` — cheap for any Feathers-compatible
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ const figbird = new Figbird({
schema,
})

export const { useQuery, useMutations, useAction, q } = createHooks(schema)
export const { useQuery, useQueryResult, useMutations, useAction, q } = createHooks(schema)

function Notes() {
const { data: notes } = useQuery(q.notes.where({ read: false }).related('author'))
const notes = useQuery(q.notes.where({ read: false }).related('author'))

return notes.map(note => <NoteRow key={note.id} note={note} />)
}
Expand Down
11 changes: 6 additions & 5 deletions demo/src/components/ActivityPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { useMemo, type ReactNode } from 'react'
import { Link } from 'react-space-router'
import { q, useQueries } from '../figbird'
import { q, useQueryResults } from '../figbird'
import { Explain } from './Explain'
import { StatusDot } from './ui'

Expand All @@ -16,7 +16,7 @@ interface ActivityEntry {
}

export function ActivityPanel() {
const [{ data: comments }, { data: reactions }, { data: issues, isFetching }] = useQueries([
const [{ data: comments }, { data: reactions }, { data: issues, isFetching }] = useQueryResults([
q.comments.orderBy('id', 'desc').limit(10).related('author'),
q.reactions.orderBy('id', 'desc').limit(6).related('user'),
q.issues.orderBy('updatedAt', 'desc').limit(6),
Expand Down Expand Up @@ -80,16 +80,17 @@ export function ActivityPanel() {
<StatusDot active={isFetching} />
<Explain
label='Parallel cross-service feed'
query={`const [comments, reactions, issues] =
useQueries([
query={`const [{ data: comments }, { data: reactions },
{ data: issues, isFetching }] =
useQueryResults([
q.comments.orderBy('id', 'desc').limit(10)
.related('author'),
q.reactions.orderBy('id', 'desc').limit(6)
.related('user'),
q.issues.orderBy('updatedAt', 'desc').limit(6),
])`}
>
<code>useQueries</code> starts all three independent roots before suspending, so this
<code>useQueryResults</code> starts all three independent roots before suspending, so this
boundary waits once instead of fetching comments, reactions, and issues serially. The
results are merged by timestamp in the component. Each stays realtime on its own service;
a teammate's comment lands here, in the list's comment count, and in the open issue
Expand Down
16 changes: 12 additions & 4 deletions demo/src/components/IssueList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@
import { Suspense, useState, useTransition } from 'react'
import { Link, useRoute } from 'react-space-router'
import { useDebouncedTransition } from 'figbird'
import { q, useQuery, type Issue, type Label, type Team, type User } from '../figbird'
import {
q,
useQuery,
useQueryResult,
type Issue,
type Label,
type Team,
type User,
} from '../figbird'
import { Explain } from './Explain'
import { StatusDot, SkeletonRows, escapeRegExp } from './ui'

Expand All @@ -29,7 +37,7 @@ export function IssueListPane() {
const [teamId, setTeamId] = useState<number | null>(null)
const [isPending, filterTransition] = useTransition()

const { data: teams } = useQuery(q.teams)
const teams = useQuery(q.teams)

const setStatusFilter = (next: StatusFilter) => filterTransition(() => setStatus(next))
const setTeamFilter = (next: number | null) => filterTransition(() => setTeamId(next))
Expand Down Expand Up @@ -134,7 +142,7 @@ function PaginatedIssueRows({ status, teamId }: { status: StatusFilter; teamId:
hasMore,
isLoadingMore,
total,
} = useQuery(
} = useQueryResult(
q.issues
.where(where)
.orderBy('updatedAt', 'desc')
Expand Down Expand Up @@ -192,7 +200,7 @@ function SearchResults({ term, typing }: { term: string; typing: boolean }) {
const escaped = escapeRegExp(term)
// No `.server()` needed: `$regex` is an operator figbird's local matcher can't
// evaluate, so the query classifies server-authoritative automatically.
const { data: issues, isFetching } = useQuery(
const { data: issues, isFetching } = useQueryResult(
q.issues
.where({ title: { $regex: escaped, $options: 'i' } })
.orderBy('updatedAt', 'desc')
Expand Down
2 changes: 1 addition & 1 deletion demo/src/components/NewIssueModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function NewIssueModal({ onClose }: { onClose: () => void }) {
function NewIssueForm({ onClose }: { onClose: () => void }) {
const m = useMutations()
const navigate = useNavigate()
const [{ data: teams }, { data: users }] = useQueries([q.teams, q.users])
const [teams, users] = useQueries([q.teams, q.users])
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [teamId, setTeamId] = useState(teams[0]?.id ?? 1)
Expand Down
2 changes: 2 additions & 0 deletions demo/src/figbird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,10 @@ export const figbird = new Figbird({ schema, adapter })
// useMutations returns its typed write proxy inside components.
export const {
useQuery,
useQueryResult,
useWindowQuery,
useQueries,
useQueryResults,
q,
useMutations,
defineQuery,
Expand Down
4 changes: 2 additions & 2 deletions demo/src/pages/IssueDetail/Comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useMemo, useState } from 'react'
import {
useAction,
useMutations,
useQuery,
useQueryResult,
type Comment,
type Reaction,
type User,
Expand All @@ -34,7 +34,7 @@ interface CommentThread {
}

export function CommentsPanel({ issueId }: { issueId: number }) {
const { data: comments, isFetching } = useQuery(issueCommentsQuery({ id: issueId }))
const { data: comments, isFetching } = useQueryResult(issueCommentsQuery({ id: issueId }))
const [replyTo, setReplyTo] = useState<number | null>(null)

const threads = useMemo<CommentThread[]>(() => {
Expand Down
2 changes: 1 addition & 1 deletion demo/src/pages/IssueDetail/Tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function nextClientTaskId(): number {
}

export function TasksPanel({ issueId, users }: { issueId: number; users: User[] }) {
const { data: tasks } = useQuery(issueTasksQuery({ id: issueId }))
const tasks = useQuery(issueTasksQuery({ id: issueId }))
const queue = useMutationQueue(issueTaskQueue, `issue:${issueId}:tasks`)
const [focusTaskId, setFocusTaskId] = useState<number | null>(null)

Expand Down
15 changes: 8 additions & 7 deletions demo/src/pages/IssueDetail/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import { useNavigate, useRoute } from 'react-space-router'
import { q, useAction, useMutating, useMutations, useQueries, useQuery } from '../../figbird'
import { q, useAction, useMutating, useMutations, useQueries, useQueryResult } from '../../figbird'
import { Explain } from '../../components/Explain'
import { StatusDot } from '../../components/ui'
import { CommentsPanel } from './Comments'
Expand Down Expand Up @@ -54,15 +54,16 @@ function IssueDetailLoaded({ issueId }: { issueId: number }) {
const navigate = useNavigate()
// The route's queries declaration warmed this exact query before the chunk arrived. The Suspense
// boundary above (keyed by issueId) renders its skeleton if we're still cold.
const { data: issue, error, isFetching, refetch } = useQuery(issueDetailQuery({ id: issueId }))
const {
data: issue,
error,
isFetching,
refetch,
} = useQueryResult(issueDetailQuery({ id: issueId }))

// Cycled through by the toolbar actions — queried rather than mirrored from the
// server seed, so they can't silently drift when the seed changes.
const [{ data: users }, { data: teams }, { data: labels }] = useQueries([
q.users,
q.teams,
q.labels,
])
const [users, teams, labels] = useQueries([q.users, q.teams, q.labels])

// One action per button: each owns its pending label; the bodies close over
// the current issue, so no arguments need threading. Writes go through `m` —
Expand Down
4 changes: 2 additions & 2 deletions demo/src/pages/Teams/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
*/

import { Link } from 'react-space-router'
import { q, useQuery } from '../../figbird'
import { q, useQueryResult } from '../../figbird'
import { Explain } from '../../components/Explain'
import { StatusDot } from '../../components/ui'

export function TeamsPage() {
const { data: teams, isFetching } = useQuery(
const { data: teams, isFetching } = useQueryResult(
q.teams
.related('members')
.related('spotlight')
Expand Down
Loading