Best Practices
Organization patterns, type safety, and SDK integration.
Organization
Group queries by resource domain:
// ✅ Good: Domain-specific query clients
class UserQueryClient extends QueryClient<"users"> {
readonly getProfile = (userId: string) => this.query({...});
readonly getUsers = () => this.query({...});
readonly createUser = () => this.mutation({...});
}
// ❌ Avoid: Scattered query definitions
const someQuery = createQuery({...});
const anotherQuery = createQuery({...});Cache Management
Invalidate related queries after mutations:
// ✅ Good: Centralized invalidation in mutation
readonly updateProject = this.mutation({
key: ["update"],
fn: updateProjectFn,
onSuccess: () => {
this.refresh("list");
this.refresh("details");
},
});
// ❌ Avoid: Manual invalidation scattered in components
useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [...] });
queryClient.invalidateQueries({ queryKey: [...] });
},
});Type Safety
Fully type all generic parameters:
// ✅ Good: Explicit types
getProjects(userId: string) {
return this.query<"list", [string], Project[], ApiError>({
key: ["list", userId],
fn: async () => {...},
});
}
// ❌ Avoid: Using any
readonly getProjects = this.query<any, any, any, any>({...});Default Options
Set appropriate defaults for resource domains:
// ✅ Good: Configure caching per domain
class ProjectsQueryClient extends QueryClient<"projects"> {
constructor(tanstackClient: TanstackQueryClient) {
super(tanstackClient, "projects", {
staleTime: 5 * 60 * 1000, // Fresh for 5 min
gcTime: 30 * 60 * 1000, // Keep unused 30 min
});
}
}
// ❌ Avoid: Default 0 values
super(tanstackClient, "projects"); // Uses 0ms for bothError Handling
Handle errors consistently:
// ✅ Good: Structured error handling
readonly deleteProject = this.mutation({
key: ["delete"],
fn: deleteProjectFn,
onError: (error) => {
if (error.status === 404) {
toast.error("Project not found");
} else if (error.status === 403) {
toast.error("Permission denied");
} else {
toast.error("Failed to delete project");
}
},
});
// ❌ Avoid: Ignoring errors
readonly deleteProject = this.mutation({
key: ["delete"],
fn: deleteProjectFn,
// No error handling
});SDK Integration
Integrate with @teardown/sdk for authenticated API calls:
import type { ApiClient } from "@teardown/sdk/api";
class ProjectsClient extends QueryClient<"projects"> {
constructor(
tanstackClient: TanstackQueryClient,
private readonly api: ApiClient
) {
super(tanstackClient, "projects");
}
getProjects(orgId: string) {
return this.query({
key: ["list", orgId],
fn: async () => {
const result = await this.api.fetch("/v1/projects", {
method: "GET",
headers: { "td-org-id": orgId },
});
if (result.error) {
throw result.error.value;
}
return result.data.projects;
},
});
}
}Auth State Changes
Clear cache when auth state changes:
function createQueryClients(sdk: TeardownSDK) {
const queryClients = new QueryClients(queryClient);
sdk.auth.onAuthStateChange((state) => {
if (state.type === "signed-out") {
// Clear all cached data on sign-out
queryClient.clear();
}
});
return queryClients;
}React Context Setup
Provide query clients via context:
const QueryClientsContext = createContext<QueryClients | null>(null);
function QueryClientsProvider({ children }) {
const sdk = useTeardownSDK();
const [queryClients] = useState(() =>
new QueryClients(queryClient, sdk.api)
);
useEffect(() => {
return () => queryClients.shutdown();
}, [queryClients]);
return (
<QueryClientsContext.Provider value={queryClients}>
{children}
</QueryClientsContext.Provider>
);
}
function useQueryClients() {
const ctx = useContext(QueryClientsContext);
if (!ctx) throw new Error("Missing QueryClientsProvider");
return ctx;
}
// Usage in components
function ProjectsList() {
const { projects } = useQueryClients();
const { data } = useQuery(projects.getProjects(orgId));
return <div>{/* ... */}</div>;
}Route Loaders
Use queries in TanStack Router loaders:
export const Route = createFileRoute("/_authed/projects/$projectId")({
loader: async ({ params }) => {
const project = await core.queries.projects
.getProject(params.projectId)
.fetch();
if (!project) {
throw redirect({ to: "/projects" });
}
return { project };
},
component: ProjectPage,
});Testing
Test queries with TanStack Query utilities:
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
describe("ProjectsClient", () => {
it("fetches project list", async () => {
const testQueryClient = new QueryClient();
const projectsClient = new ProjectsClient(testQueryClient);
const { result } = renderHook(
() => useQuery(projectsClient.getProjects("user-123")),
{
wrapper: ({ children }) => (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
),
}
);
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(mockProjects);
});
});