Teardown

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 both

Error 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);
  });
});