Skip to content

🔄 Synced from castyou-frontend/docs/PATTERNS.md — edit it there, not here.

CastYou Frontend — Patterns & Conventions

Component Pattern

All components follow the same structure:

tsx
// 1. Props interface first
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
  variant?: 'default' | 'elevated';
}

// 2. Named export (no default exports for components — easier to refactor)
export function Card({ variant = 'default', className, ...props }: CardProps) {
  return <div className={cn(variants({ variant }), className)} {...props} />;
}

Rules:

  • Named exports for all components (not default)
  • Spread ...props to preserve native HTML attributes
  • Accept and forward className so consumers can extend styles with cn()
  • Never inline Tailwind class strings that should be design tokens — use the design system

Design System Usage

Always import from @castyou/design-system — never copy-paste components between apps.

tsx
// Good
import { Button, Card, cn } from '@castyou/design-system';

// Bad — duplicating components locally
import { Button } from '../../components/Button';

Use cn() to conditionally extend classes:

tsx
<Button className={cn('w-full', isLoading && 'opacity-50')} />

Server State vs Client State

React Query owns server state (anything that comes from the API):

  • User profiles, jobs, discover feed, applications

Zustand owns client/session state (UI decisions, not API data):

  • Auth token + user object
  • UI preferences (theme, sidebar open/closed)
  • Ephemeral form state is local useState — not global

Never mirror server state into Zustand. If you need a derived value from server state, compute it inside the component or a selector:

tsx
// Good
const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: fetchProfile });
const isComplete = (profile?.skills.length ?? 0) > 0;

// Bad
const isComplete = useProfileStore((s) => s.isComplete); // mirrors server state

Data Fetching Pattern

Each domain gets a src/lib/queries/<domain>.ts file with the GraphQL documents, and a src/hooks/use<Domain>.ts file with typed React Query hooks.

ts
// src/lib/queries/jobs.ts
export const JOBS_QUERY = gql`
  query Jobs($first: Int, $filter: JobFilterInput) {
    jobs(first: $first, filter: $filter) {
      edges { id title paymentType ... }
      pageInfo { hasNextPage totalCount }
    }
  }
`;

// src/hooks/useJobs.ts
export function useJobs(filter?: JobFilterInput) {
  return useQuery({
    queryKey: ['jobs', filter],
    queryFn: () => gqlClient.request(JOBS_QUERY, { first: 20, filter }),
  });
}

Pages import the hook, not the raw query:

tsx
// Good
const { data } = useJobs({ isRemote: true });

// Bad
const { data } = useQuery({ queryKey: ['jobs'], queryFn: () => gqlClient.request(JOBS_QUERY) });

Routing (App)

All routes are lazy-loaded in App.tsx. ProtectedRoute wraps any route that requires authentication.

tsx
const DiscoverPage = lazy(() => import('./pages/discover/DiscoverPage'));

<Route path="/" element={<ProtectedRoute><DiscoverPage /></ProtectedRoute>} />

Role-specific routes (e.g., producer-only job posting) add a role check inside ProtectedRoute or at the top of the page component.

Error Handling

  • Wrap top-level pages with React Error Boundaries
  • Use React Query's isError + error states to show inline error UI, not toast-only errors
  • Form mutations use onError callback to display field-level errors from GraphQL extensions

File & Folder Naming

  • Files: PascalCase for components (Button.tsx, DiscoverPage.tsx), camelCase for everything else (queryClient.ts, useJobs.ts)
  • Folders: camelCase (pages/discover/, hooks/, lib/)
  • One component per file — no barrel files in pages/ (lazy import needs a dedicated file)

Tailwind Conventions

  • Mobile-first: base styles are mobile, add sm: / md: / lg: for larger breakpoints
  • Use gap-* not space-* for flex/grid layouts
  • Avoid !important overrides — if you need them, the component API is wrong
  • Color values always come from the design system palette (brand-500, neutral-200) — no raw hex

Design System Component Variants

New variants on design-system components use cva (class-variance-authority):

ts
const buttonVariants = cva('base-classes', {
  variants: {
    variant: { default: '...', outline: '...' },
    size: { sm: '...', md: '...', lg: '...' },
  },
  defaultVariants: { variant: 'default', size: 'md' },
});

This keeps variant logic co-located with the component and gives TypeScript-enforced prop types.