Appearance
🔄 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
...propsto preserve native HTML attributes - Accept and forward
classNameso consumers can extend styles withcn() - 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 stateData 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+errorstates to show inline error UI, not toast-only errors - Form mutations use
onErrorcallback to display field-level errors from GraphQL extensions
File & Folder Naming
- Files:
PascalCasefor components (Button.tsx,DiscoverPage.tsx),camelCasefor 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-*notspace-*for flex/grid layouts - Avoid
!importantoverrides — 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.