TanStack Query-inspired React hooks for Convex with enhanced developer experience. Leverages Convex's built-in real-time sync engine - no additional caching needed!
Convex already handles all the complex stuff (caching, retry logic, real-time subscriptions), but the basic useQuery hook lacks the developer experience of TanStack Query. This library provides:
- β
Full TanStack Query-style status system -
status: 'loading' | 'error' | 'success' - β
Enhanced loading states -
isLoadingvsisFetchingdistinction - β
Smooth query transitions -
keepPreviousDatafor flicker-free pagination - β Query caching support - Optional cache provider for extended subscription lifetimes
- β
Mutation callbacks -
onSuccess,onError,onSettled - β Advanced TypeScript inference - Perfect type safety
- β Zero additional complexity - Convex handles the hard stuff!
npm install better-convex-query
# or
bun add better-convex-queryimport { useQuery } from 'better-convex-query';
import { api } from '../convex/_generated/api';
function UserProfile({ userId }: { userId: string }) {
const {
data,
error,
status,
isLoading,
isFetching,
isPending,
isSuccess,
isError
} = useQuery(
api.users.getUser,
{ userId },
{ enabled: !!userId }
);
if (isLoading) return <div>π Loading...</div>;
if (isError) return <div>β Error: {error?.message}</div>;
if (!data) return null;
return (
<div>
<h1>Status: {status}</h1>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}import { useQuery } from 'better-convex-query';
import { api } from '../convex/_generated/api';
function ProjectsList() {
const [page, setPage] = useState(0);
const { data, isPlaceholderData, isFetching } = useQuery(
api.projects.list,
{ page },
{ keepPreviousData: true }
);
return (
<div>
{data?.projects.map(project => (
<div key={project.id}>{project.name}</div>
))}
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 0}
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
{isFetching && <span>Loading...</span>}
</div>
);
}import { useMutation } from 'better-convex-query';
import { api } from '../convex/_generated/api';
function UpdateUserForm({ userId }: { userId: string }) {
const updateUser = useMutation(
api.users.updateUser,
{
onSuccess: (data, variables) => {
console.log('β
User updated!', data);
},
onError: (error, variables) => {
console.error('β Update failed:', error);
},
onSettled: (data, error, variables) => {
console.log('π Update completed');
}
}
);
const handleSubmit = async (name: string) => {
try {
await updateUser.mutate({ userId, name });
} catch (error) {
// Error already handled in onError callback
}
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(e.target.name.value);
}}>
<input name="name" type="text" disabled={updateUser.isPending} />
<button type="submit" disabled={updateUser.isPending}>
{updateUser.isPending ? 'πΎ Saving...' : 'πΎ Save'}
</button>
{updateUser.error && <span>β {updateUser.error.message}</span>}
</form>
);
}import { useCacheQuery, ConvexQueryCacheProvider } from 'better-convex-query';
import { api } from '../convex/_generated/api';
// Wrap your app
function App() {
return (
<ConvexProvider client={convex}>
<ConvexQueryCacheProvider expiration={300000}>
<YourApp />
</ConvexQueryCacheProvider>
</ConvexProvider>
);
}
// Use cached queries
function UserProfile({ userId }: { userId: string }) {
const { data } = useCacheQuery(
api.users.getUser,
{ userId }
);
return <div>{data?.name}</div>;
}function useQuery<TQuery extends FunctionReference<'query'>>(
query: TQuery,
args: TArgs extends Record<string, never> ? 'skip' | undefined : TArgs,
options?: UseQueryOptions
): UseQueryResult<FunctionReturnType<TQuery>>enabled?: boolean- Whether to fetch data (default:true)keepPreviousData?: boolean- Show previous data while new query loads (default:false)
data: TData | undefined- The query result dataerror: Error | undefined- Any error that occurredstatus: 'loading' | 'error' | 'success'- TanStack-style statusisLoading: boolean- Initial load onlyisFetching: boolean- Any load (including background refetches)isPending: boolean- Loading or error stateisSuccess: boolean- Has successful dataisError: boolean- Has errorisPlaceholderData: boolean- Whether showing previous data during transition
function useMutation<TMutation extends FunctionReference<'mutation'>>(
mutation: TMutation,
options?: UseMutationOptions
): UseMutationResult<FunctionReturnType<TMutation>, Error, FunctionArgs<TMutation>>onSuccess?: (data, variables) => void- Called on successful mutationonError?: (error, variables) => void- Called on mutation erroronSettled?: (data, error, variables) => void- Called when mutation completes
mutate: (variables) => Promise<TData>- Trigger the mutationmutateAsync: (variables) => Promise<TData>- Same as mutate (alias)isPending: boolean- Whether mutation is runningerror: Error | undefined- Any error from last mutationstatus: 'idle' | 'pending' | 'error' | 'success'- Mutation statusreset: () => void- Reset error and status
const { status, isLoading, isFetching, isSuccess, isError } = useQuery(query, args);
// status: 'loading' | 'error' | 'success'const { isLoading, isFetching } = useQuery(query, args);
// isLoading = initial load only
// isFetching = any load (initial + background refetch)const { data, isPlaceholderData } = useQuery(
api.projects.list,
{ page },
{ keepPreviousData: true }
);
// Shows previous data while new query loads - perfect for pagination!// Keep query subscriptions alive for 5 minutes after unmount
const { data } = useCacheQuery(api.users.getUser, { userId });
// Reduces unnecessary re-fetches when navigatingconst { mutate } = useMutation(mutation, {
onSuccess: (data, variables) => { /* handle success */ },
onError: (error, variables) => { /* handle error */ },
onSettled: (data, error, variables) => { /* cleanup */ }
});// Types are automatically inferred from your Convex functions
const { data } = useQuery(api.users.getUser, { userId: '123' });
// data is automatically typed as the return type of api.users.getUser// Original Convex hooks still available
import { useConvexQuery, useConvexMutation } from 'better-convex-query';# Install dependencies
bun install
# Build the library
bun run build
# Watch mode for development
bun run dev
# Run tests
bun testThe library includes comprehensive tests. Run with:
bun testSince bundle size doesn't matter for this library, we prioritize:
- β Perfect TypeScript inference
- β Comprehensive error handling
- β Full feature parity with TanStack Query patterns
- β Zero runtime overhead (just wrappers around Convex)
Convex already provides:
- β Real-time subscriptions
- β Automatic caching
- β Retry logic
- β Optimistic updates
- β Connection management
We add:
- β Better developer experience (TanStack-style API)
- β Enhanced loading states
- β Mutation callbacks
- β Perfect TypeScript support
We don't add:
- β Additional caching (Convex handles this)
- β Retry logic (Convex handles this)
- β Complex state management (Convex handles this)
- β Bundle bloat (just thin wrappers)
MIT