Data Fetching Patterns

The Employ platform follows specific patterns for fetching, managing, and displaying data. This guide shows you how to properly integrate with our API layer and build data-driven components.

Core Data Fetching Principles

  1. Always use composables for data fetching logic
  2. Leverage the tRPC-based API caller for type-safe API requests
  3. Separate data fetching from presentation
  4. Handle loading and error states consistently

The API Caller Pattern

All API calls should be made through the useApiCaller() composable, which provides a type-safe interface to our tRPC backend.

const { apiCaller } = useApiCaller();

// Example of fetching data
const result = await apiCaller.application.byJobId.query({
  jobId: jobId.value,
  limit: 100
});

Composable-Based Data Fetching

For reusable data-fetching logic, create dedicated composables:

// Example: modules/platform/shared/composables/useApplications.ts
export function useApplications(jobId: ComputedRef<string>) {
  const { apiCaller } = useApiCaller();
  const { toast } = useToast();

  const { data, status, error, refresh } = useAsyncData(
    `applications-${jobId.value}`,
    async () => {
      if (!jobId.value) return { items: [], total: 0 };

      try {
        const result = await apiCaller.application.byJobId.query({
          jobId: jobId.value,
          limit: 100,
        });

        return {
          items: [...result.items],
          nextCursor: result.nextCursor
        };
      } catch (error) {
        throw error;
      }
    }
  );

  // Error handling
  watchEffect(() => {
    if (error.value) {
      toast({
        variant: "error",
        title: "Error",
        description: error.value.message || "Failed to load applications",
        duration: 5000,
      });
    }
  });

  return {
    applicationsData: data,
    applicationsStatus: status,
    applicationsError: error,
    refreshApplications: refresh
  };
}

Using Data in Components

When using data in components, unpack the composable results and pass them to child components:

<script setup lang="ts">
// In a page component
const jobId = ref('job-123');
const { 
  applicationsData, 
  applicationsStatus, 
  applicationsError 
} = useApplications(computed(() => jobId.value));
</script>

<template>
  <ApplicationList
    :applications="applicationsData?.items"
    :loading="applicationsStatus === 'pending'"
    :error="applicationsError"
  />
</template>

Real-World Example: Applications Page

Here's how the Applications page implements these patterns:

<script setup lang="ts">
// Using the apiCaller for direct data fetching
const { apiCaller } = useApiCaller();
const { currentOrganization } = await useUser();

const { data: applications, status, error, refresh } = await useAsyncData(
  `applications-${currentOrganization.value?.id}`,
  async () => {
    if (!currentOrganization.value?.id) return { items: [], total: 0 };

    try {
      // First fetch jobs for the organization
      const jobsResult = await apiCaller.job.listByOrganization.query({
        organizationId: currentOrganization.value.id,
      });

      if (!jobsResult.items || jobsResult.items.length === 0) {
        return { items: [], total: 0 };
      }

      // Get all job IDs
      const jobIds = jobsResult.items.map(job => job.id);

      // Create a job lookup map
      const jobMap = new Map();
      jobsResult.items.forEach(job => {
        jobMap.set(job.id, job);
      });

      // Fetch applications for all jobs
      const result = await apiCaller.application.byJobId.query({
        jobId: jobIds,
        limit: 100
      });

      // Enrich applications with job data
      const items = result.items.map(app => ({
        ...app,
        job: {
          id: app.jobId,
          title: jobMap.get(app.jobId)?.title || "Unknown Job",
          location: jobMap.get(app.jobId)?.location || null,
        },
        applicant: {
          id: app.applicantId,
          name: "Applicant",
          email: "",
        },
      }));

      return { items, total: items.length };
    } catch (error) {
      throw error;
    }
  },
);

// Update data when organization changes
watch(() => currentOrganization.value?.id, () => {
  refresh();
});
</script>

Best Practices

  1. Cache Keys: Use unique cache keys for useAsyncData based on relevant parameters
    useAsyncData(`applications-${jobId.value}`, async () => {
      // ...
    })
    
  2. Reactive Dependencies: Update data when dependencies change
    watch(() => currentOrganization.value?.id, () => {
      refresh();
    });
    
  3. Error Handling: Always include error handling in your composables
    watchEffect(() => {
      if (error.value) {
        toast({
          variant: "error",
          title: "Error",
          description: error.value.message,
        });
      }
    });
    
  4. Loading States: Always pass loading state to components
    <ApplicationList :loading="status === 'pending'" />
    
  5. Data Transformation: Transform API data to match the component's expected format
    const items = result.items.map(app => ({
      ...app,
      job: {
        id: app.jobId,
        title: jobMap.get(app.jobId)?.title || "Unknown Job",
      },
    }));
    

By following these patterns, you'll create maintainable, reusable data-fetching logic that keeps your components clean and focused on presentation.