React Hooks
@pressw/threads
provides a comprehensive set of React hooks for building reactive thread-based interfaces with optimistic updates, caching, and real-time synchronization.
Setup
ThreadsProvider
The ThreadsProvider
component sets up the React Query client and provides thread context to child components.
import { ThreadsProvider } from '@pressw/threads/react';
import { createAdapter } from '@pressw/threads/adapters';
function App() {
const adapter = createAdapter({
// ... adapter configuration
});
return (
<ThreadsProvider
adapter={adapter}
userId={currentUser.id}
getUserContext={async (userId) => ({
id: userId,
organizationId: currentUser.organizationId,
tenantId: currentUser.tenantId,
})}
>
<YourApp />
</ThreadsProvider>
);
}
Props
adapter
- Database adapter instance (required)userId
- Current user ID (required)getUserContext
- Function to retrieve user context (optional)queryClient
- Custom React Query client (optional)children
- Child components
Query Hooks
useThreads
Fetches a list of threads with automatic caching and refetching.
const { data, isLoading, error, refetch } = useThreads(options);
Parameters
options
(optional) - List configurationlimit
- Number of threads per page (default: 10)offset
- Number of threads to skipsearch
- Search query for thread titlesorderBy
- Sort order: "asc" or "desc" (default: "desc")
Returns
data
- Array of thread objectsisLoading
- Loading stateerror
- Error object if query failedrefetch
- Function to manually refetch
Example
function ThreadList() {
const [search, setSearch] = useState('');
const { data: threads, isLoading } = useThreads({
limit: 20,
search,
orderBy: 'desc',
});
if (isLoading) return <Spinner />;
return (
<>
<SearchInput value={search} onChange={setSearch} />
<div className="thread-list">
{threads?.map((thread) => (
<ThreadCard key={thread.id} thread={thread} />
))}
</div>
</>
);
}
useThread
Fetches a single thread by ID with caching.
const { data, isLoading, error } = useThread(threadId);
Parameters
threadId
- The ID of the thread to fetch
Returns
data
- Thread objectisLoading
- Loading stateerror
- Error object if query failed
Example
function ThreadDetail({ threadId }: { threadId: string }) {
const { data: thread, isLoading } = useThread(threadId);
if (isLoading) return <Skeleton />;
if (!thread) return <NotFound />;
return (
<article>
<h1>{thread.title}</h1>
<time>{new Date(thread.createdAt).toLocaleDateString()}</time>
{thread.metadata && (
<div>
<Badge>{thread.metadata.category}</Badge>
<Priority level={thread.metadata.priority} />
</div>
)}
</article>
);
}
useInfiniteThreads
Fetches threads with infinite scroll support.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteThreads(options);
Parameters
options
(optional) - List configuration (same asuseThreads
but withoutoffset
)
Returns
data
- Pages of thread resultsfetchNextPage
- Function to load next pagehasNextPage
- Whether more pages are availableisFetchingNextPage
- Loading state for next page
Example
function InfiniteThreadFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteThreads({
limit: 20,
});
// Intersection Observer for infinite scroll
const observerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 },
);
if (observerRef.current) {
observer.observe(observerRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div className="feed">
{data?.pages.map((page, i) => (
<Fragment key={i}>
{page.threads.map((thread) => (
<ThreadCard key={thread.id} thread={thread} />
))}
</Fragment>
))}
{hasNextPage && (
<div ref={observerRef} className="loading-trigger">
{isFetchingNextPage && <Spinner />}
</div>
)}
</div>
);
}
Mutation Hooks
All mutation hooks include:
- Optimistic updates for instant UI feedback
- Automatic cache invalidation
- Error handling with rollback
- Loading states
useCreateThread
Creates new threads with optimistic updates.
const createThread = useCreateThread();
Returns
Mutation object with:
mutate
- Mutation function (fire and forget)mutateAsync
- Async mutation function (returns promise)isPending
- Loading stateerror
- Error object if mutation failedreset
- Reset mutation state
Example
function CreateThreadDialog() {
const [open, setOpen] = useState(false);
const createThread = useCreateThread();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
try {
const thread = await createThread.mutateAsync({
title: formData.get('title') as string,
metadata: {
category: formData.get('category') as string,
priority: formData.get('priority') as string,
tags: formData
.get('tags')
?.toString()
.split(',')
.map((t) => t.trim()),
},
});
setOpen(false);
// Navigate to new thread
router.push(`/threads/${thread.id}`);
} catch (error) {
console.error('Failed to create thread:', error);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Thread title" required autoFocus />
<select name="category">
<option value="general">General</option>
<option value="support">Support</option>
<option value="feature">Feature Request</option>
</select>
<select name="priority">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
</select>
<input name="tags" placeholder="Tags (comma separated)" />
<button type="submit" disabled={createThread.isPending}>
{createThread.isPending ? 'Creating...' : 'Create Thread'}
</button>
</form>
</DialogContent>
</Dialog>
);
}
useUpdateThread
Updates existing threads with optimistic updates.
const updateThread = useUpdateThread();
Returns
Same mutation object as useCreateThread
Example
function EditableThreadTitle({ thread }: { thread: Thread }) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(thread.title);
const updateThread = useUpdateThread();
const handleSave = async () => {
if (title !== thread.title) {
await updateThread.mutateAsync({
threadId: thread.id,
title,
});
}
setIsEditing(false);
};
if (isEditing) {
return (
<div className="editable-title">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') {
setTitle(thread.title);
setIsEditing(false);
}
}}
autoFocus
/>
</div>
);
}
return (
<h1 onClick={() => setIsEditing(true)} className="editable">
{thread.title}
<EditIcon />
</h1>
);
}
useDeleteThread
Deletes threads with optimistic removal from UI.
const deleteThread = useDeleteThread();
Returns
Same mutation object as other mutations
Example
function ThreadActions({ thread }: { thread: Thread }) {
const deleteThread = useDeleteThread();
const router = useRouter();
const handleDelete = async () => {
const confirmed = await confirm({
title: 'Delete Thread',
description: 'Are you sure you want to delete this thread?',
confirmText: 'Delete',
cancelText: 'Cancel',
});
if (confirmed) {
try {
await deleteThread.mutateAsync(thread.id);
router.push('/threads');
toast.success('Thread deleted');
} catch (error) {
toast.error('Failed to delete thread');
}
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreIcon />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleDelete}>
<TrashIcon />
Delete Thread
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
Advanced Patterns
Optimistic Updates
All mutations automatically handle optimistic updates:
function QuickActions() {
const createThread = useCreateThread();
const updateThread = useUpdateThread();
// Create - immediately shows in UI
const handleQuickCreate = () => {
createThread.mutate({
title: 'Quick Note',
metadata: { type: 'note' },
});
};
// Update - immediately reflects change
const handleToggleStatus = (thread: Thread) => {
updateThread.mutate({
threadId: thread.id,
metadata: {
...thread.metadata,
status: thread.metadata?.status === 'open' ? 'closed' : 'open',
},
});
};
return (
<div>
<button onClick={handleQuickCreate}>Quick Note</button>
{/* UI updates immediately, rolls back on error */}
</div>
);
}
Custom Query Keys
Use custom query keys for fine-grained cache control:
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { threadKeys } from '@pressw/threads/react';
function CustomThreadQuery() {
const queryClient = useQueryClient();
// Custom query with thread keys
const { data } = useQuery({
queryKey: [...threadKeys.all, 'custom'],
queryFn: async () => {
// Custom query logic
},
});
// Invalidate specific queries
const handleRefresh = () => {
queryClient.invalidateQueries({
queryKey: threadKeys.lists(),
});
};
}
Error Handling
Implement global error handling:
function ThreadsErrorBoundary({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
// Global error handler
queryClient.setMutationDefaults(['threads'], {
onError: (error) => {
toast.error(error.message || 'Something went wrong');
},
});
return <>{children}</>;
}
Prefetching
Prefetch thread data for better performance:
function ThreadLink({ threadId, children }: { threadId: string; children: ReactNode }) {
const queryClient = useQueryClient();
const handleHover = () => {
queryClient.prefetchQuery({
queryKey: threadKeys.detail(threadId),
queryFn: () => threadClient.getThread(userId, threadId),
});
};
return (
<Link href={`/threads/${threadId}`} onMouseEnter={handleHover}>
{children}
</Link>
);
}
Real-time Updates
Integrate with WebSocket for real-time updates:
function useThreadsRealtime() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);
ws.onmessage = (event) => {
const { type, threadId, data } = JSON.parse(event.data);
switch (type) {
case 'thread.created':
queryClient.invalidateQueries({ queryKey: threadKeys.lists() });
break;
case 'thread.updated':
queryClient.setQueryData(threadKeys.detail(threadId), data);
break;
case 'thread.deleted':
queryClient.invalidateQueries({ queryKey: threadKeys.all });
break;
}
};
return () => ws.close();
}, [queryClient]);
}
Best Practices
- Use the Provider - Always wrap your app with
ThreadsProvider
- Handle Loading States - Show appropriate loading indicators
- Handle Errors - Provide user-friendly error messages
- Optimize Queries - Use pagination and search to limit data
- Prefetch When Possible - Improve perceived performance
- Invalidate Wisely - Only invalidate affected queries
- Use Optimistic Updates - Provide instant feedback
- Handle Edge Cases - Empty states, network errors, etc.