A comprehensive guide for writing clean, maintainable, and type-safe code for the bigRAG application.
Every rule can and will be broken in certain cases. When you deviate from these guidelines:
- ✅ Document the reason outside source code in the commit message, PR description, or docs
- ✅ Make it intentional - not accidental
- ✅ Document the trade-off - what you gained vs what you gave up
function processLargeDataset(data: unknown) {
if (!isLargeDataset(data)) {
throw new Error("Invalid dataset")
}
return transform(data)
}Rule breaks should always be intentional, not accidental. Do not add explanatory source comments or docstrings under api/bigrag/, sdks/typescript/src/, sdks/python/src/, app/, or website/; use clearer names, smaller functions, or external review notes instead. Functional tool directives such as # type: ignore, # ruff:, // @ts-..., // biome-ignore, and // eslint-... are allowed when the tool requires them.
- Core Principles
- Rule Severity
- Cross-Cut Defaults
- Tech Stack Overview
- Naming Conventions
- TypeScript Usage
- React Patterns
- State Management
- Data Fetching with TanStack Query
- Component Composition
- Performance Guidelines
- Error Boundaries
- Styling with Tailwind CSS
- Routing with TanStack Router
- Accessibility
- General TypeScript/JavaScript Coding Guidelines
- Code Formatting & Linting with Biome
- Python Backend
- FastAPI Patterns
- Database & Async Patterns
- Caching with Redis
- TypeScript SDK Design
- Summary
- References
The bigRAG codebase is built on these fundamental principles:
Keep React components focused on presentation and user interaction, not business logic.
Components should:
- Render UI based on props and state
- Handle user events by calling external functions
- Manage simple UI state (modals, form inputs)
Components should NOT:
- Contain complex business logic
- Include data transformation logic
Place code next to where it's used, not grouped by technical type.
✅ DO: Place helper files next to the single component that uses them ❌ DON'T: Create shared folders for single-use code ✅ DO: Only use shared folders when code is used by 2+ files
Make data flow visible and dependencies clear.
- Use pattern matching over conditional operators
- Pass dependencies as explicit parameters
- Avoid hiding business rules in control flow
Embrace functional programming principles pragmatically:
- Immutability: Transform data, don't mutate it (🔴 Must)
- Pure functions: Same input → same output, no side effects (🟡 Default)
- Composition: Build complex operations from simple ones (🟡 Default)
- Prefer array methods: Use
map,filter,reduceover loops (🟢 Guideline)- Loops are allowed when: Early exit is required, performance is critical, or readability is improved
Leverage TypeScript to catch errors at compile time:
- Strict mode enabled
- Explicit type annotations for function signatures
- No
anytypes (useunknownif needed) - Use discriminated unions for state management
Not all rules are equally important. Use these tiers to guide your decisions:
These rules protect against bugs, security issues, or severe maintainability problems:
- Thin React Layer - Business logic must be extracted from components
- Type safety - No
anytypes, useunknown+ type guards (useRecord<string, unknown>for objects) - Immutability - Don't mutate data structures
- Never use useEffect for data fetching - Always use TanStack Query
These rules represent best practices but allow pragmatic exceptions:
- TanStack Query for async data - Don't manually manage loading/error states with useState
- Extract useEffect - Effects in components should be in custom hooks (≤5 lines can stay inline)
- Avoid query waterfalls - Split dependent queries into separate components
- Handle query states independently - Don't group loading/error states with
|| - Use useQueries for parallel queries - More concise than multiple useQuery calls
- Use generics to preserve types - Don't lose type information in utility functions
- Pattern matching over conditionals - Use ts-pattern for complex conditions
- Readonly modifiers - Mark data as readonly when it won't change
- Custom hooks for logic - Avoid custom hooks for business logic (use for DOM/framework APIs)
- Pure functions - Extract logic to pure functions
These rules improve code quality but are stylistic preferences:
- Array methods over loops - Prefer
map/filter/reduce(loops OK for performance/clarity) - Component size - Keep components under 80 lines (flexible based on complexity)
- Co-location - Place code next to usage (balance with reusability)
- Explaining variables - Extract complex expressions (use judgment)
When in doubt: Follow existing patterns and prioritize clarity over dogma. If a rule seems wrong for your case, document why and discuss with the team.
These rules apply across the monorepo. They adapt useful maintenance habits from other agent-facing repos while staying aligned with bigRAG's current stack and package boundaries.
- Treat changes as public-facing work: keep names precise, avoid throwaway wording, and leave the surrounding code cleaner than you found it.
- Use conventional commit and PR titles with lowercase scopes and direct summaries:
fix:,feat:,chore:,docs:,refactor:,perf:, ortest:. - Finish with the relevant formatter, linter, typecheck, build, compile, or runtime smoke check for the files touched.
- Keep new code warning-free. Do not add lint, type, accessibility, or format suppressions to bypass work; only keep tool pragmas that are required, narrow, and explained outside source code.
- Name semantic theme tokens after their purpose, not their implementation. Prefer tokens such as
canvas,panel,control,ink,muted,danger, orwarningover duplicated color names. - Do not create app tokens that only restate Tailwind defaults. Use built-in utilities directly unless the value represents a real bigRAG concept.
- Prefer Tailwind utilities for styling. Keep global CSS limited to tokens, global element rules, keyframes, pseudo-elements, browser quirks, and third-party selectors that utilities cannot express clearly.
- Reach for named Tailwind utilities first. Use arbitrary values only when the design system has no equivalent size, color, selector, or measurement.
- Revisit custom class hooks when editing nearby UI. Delete them when inline utilities, component props, or a shared primitive can express the same result.
- Keep Tailwind class lists inline for feature UI. Extract repeated styling into a component, component variant, or shared primitive instead of a one-off class string constant.
- Component primitives may centralize variants with CVA,
cn, or the repo's existing helper, but feature pages should not grow private styling registries. - Use Tailwind
size-*utilities for square icon and control boxes instead of pairing matching width and height classes. - In MDX-rendered content or third-party selector CSS, prefer Tailwind
@applyfor styles that are expressible as utilities. Use raw CSS for pseudo-elements, data attributes, browser-specific selectors, or values Tailwind cannot represent well. - Do not add external fonts without a product-level reason. Preserve existing app and docs font choices instead of introducing another font family for a local change.
- Shared helpers belong in a clearly named domain module once two or more files need them. Do not duplicate parsers or data-shaping logic across feature files.
- Keep single-use helpers beside their caller. Promote them only when reuse is real.
- Group clusters that share a domain or filename prefix into a folder rather than leaving broad flat lists of related files.
- Inside a domain folder, keep child filenames short and specific. Avoid repeating the parent folder name unless it removes ambiguity.
- Prefer descriptive module names over placeholders such as
items,panels,list, orcard. - Use
index.tsorindex.tsxwhen the file name would otherwise repeat the parent folder name exactly. - Split files before they become difficult to scan. Around 300 lines is the point to look for a route, component, service, provider, or stage boundary.
- Use
interfacefor component props and shared object shapes. Usetypefor unions, function aliases, mapped types, and utility-composed shapes. - Do not add
any. Parse unknown inputs into explicit shapes, narrow with guards, or model the allowed variants. - Omit optional object properties when a value is absent. Avoid serializing placeholder
nullorundefinedfields at API boundaries. - Represent loading and renderer states with discriminated unions instead of layered nullable sentinels.
- Use an empty string for absent renderer-only string state such as selected ids or input values only when that state never crosses an API boundary.
- Use a bare
return;for no-value exits. - Prefer conditional object spreads for optional JSX props instead of passing absent props through ternaries.
- Avoid single-use constants. Inline the value unless a name removes meaningful duplication, clarifies a non-obvious rule, or protects against drift.
- Keep local prop names contextual. In a component that owns one panel,
open,onOpen,value, andonValueChangeare clearer than repeating the parent feature name. - Order interface members, type members, JSX props, destructured bindings, hook dependencies, and object constants by increasing line length when it improves scanning and does not fight framework conventions.
- Use effects only to synchronize with something outside React: DOM APIs, storage, timers, observers, subscriptions, sockets, or analytics.
- Do not use effects for derived values that can be computed during render.
- Every timer, animation frame, listener, observer, subscription, watcher, and polling loop must have one owner and a matching cleanup path.
- Avoid duplicate polling or duplicate listeners for the same source. If high-frequency updates drive visible UI, batch them through animation frames or a single state update path.
- Keep cached UI surfaces fast. Popovers, pickers, menus, and frequently opened panels should show the last known useful data and refresh in the background.
- Avoid layout jumps during hydration or refetch. If there is no meaningful content yet, prefer a stable empty surface over noisy loaders.
- Keep primary user-facing content direct. Put diagnostics, metadata, debug payloads, traces, and tool output behind compact expandable details.
- Extract multi-branch render logic into named helpers or child components. Hoist repeated permission, role, mode, or status checks into booleans before JSX.
- Avoid placeholder ternaries that compute unused values. Split the branch or extract a helper so each path computes only what it renders.
- Use hover backgrounds only when a filled selected or active affordance is needed. Otherwise prefer text, border, or icon-color feedback.
- For small icon-only controls, keep the visual size stable and expand the hit target with padding or a pseudo-element.
- In expandable rows, keep stable identifiers visible in the row title and reserve the expanded body for supporting details, output, diffs, or long text.
- Read browser runtime configuration through
app/src/config/runtime.ts, not scatteredimport.meta.envchecks. - Read backend bootstrap settings through
api/bigrag/config.py, not directos.environcalls in services or routers. - Keep SDK environment fallbacks contained to the SDK client boundary. Do not leak
process.envreads into resource modules. - When adding or removing configuration, update
.env.example,bigrag.toml, Railway variables, Docker/deployment docs, and runtime settings docs together so operators see one coherent setup story.
The app uses modern web technologies:
- React 19 - UI framework with modern hooks and concurrent features
- TypeScript 6 - Type-safe JavaScript with strict mode
- Vite - Client-side React build tool and dev server for the admin UI
- FastAPI - Python backend framework (API server, in
api/directory) - TanStack Query v5 - Async state management for data fetching
- TanStack Form - Headless form state, validation, reset, and submit handling for complex admin UI forms
- asyncpg - Fast PostgreSQL driver (Python backend uses asyncpg, not Drizzle)
- Zod - Runtime schema validation for API inputs and form data
- TanStack Router - File-based client-side routing for the admin UI
- Tailwind CSS 4 - Utility-first CSS framework
- Base UI - Unstyled, accessible component primitives
- Lucide Icons - Clean icon library
- Recharts - Composable charting library
- Biome - Fast formatter and linter
- React Components:
snake-case.tsx(e.g.,user-profile.tsx) - Utility Files:
kebab-case.ts(e.g.,format-date.ts) - Hooks:
use-*.ts(e.g.,use-profile.ts) - Types:
*.types.ts(e.g.,profile.types.ts) - Route Files: TanStack Router file route conventions in
app/src/routes/(e.g.,_dashboard.collections.$name.documents.tsx)
Use descriptive, intention-revealing names:
// Good
const getUserProfile = (name: string): Promise<Profile> => { ... }
const isNameAvailable = (name: string): boolean => { ... }
const MAX_BIO_LENGTH = 500
// Avoid
const gUP = (n: string): Promise<Profile> => { ... }
const check = (n: string): boolean => { ... }
const x = 500Prefix with is, has, should, or can:
// Good
const isLoading = status === 'pending'
const hasAvatar = !!avatarUrl
const canEditProfile = checkPermission(user, 'edit')
const shouldShowBanner = isExpiringSoon && !dismissed
// Avoid
const loading = status === 'pending'
const avatar = !!avatarUrl
const editProfile = checkPermission(user, 'edit')// Use PascalCase for types and interfaces
interface UserProfile {
name: string
email: string
}
type ProfileStatus = 'loading' | 'success' | 'error'
// Use descriptive names for generics
function getProperty<TObject, TKey extends keyof TObject>(
obj: TObject,
key: TKey
): TObject[TKey] {
return obj[key]
}
// Props interfaces: <ComponentName>Props
interface ProfileCardProps {
name: string
username: string
}Use UPPER_SNAKE_CASE for true constants:
// Constants that represent fixed values
const MAX_NAME_LENGTH = 253
const MAX_FILE_SIZE = 10_485_760
const API_BASE_URL = 'https://api.bigrag.xyz' as constAlways define types for function parameters and return values:
// Good
interface GetProfileParams {
readonly name: string
readonly includeRecords?: boolean
}
function getProfile(params: GetProfileParams): Promise<Profile> {
// ...
}
// Avoid
function getProfile(params) {
// ...
}// Good
interface UserProfile {
readonly name: string
readonly tags: readonly string[]
}
const processTags = (tags: readonly string[]): readonly string[] => {
return tags.filter(isValid)
}
// Avoid
interface UserProfile {
name: string
tags: string[]
}
const processTags = (tags: string[]): string[] => {
return tags.filter(isValid)
}Use type guards and discriminated unions:
// Good - Discriminated union
type RequestState =
| { status: 'idle' }
| { status: 'pending'; requestId: string }
| { status: 'success'; requestId: string; data: unknown }
| { status: 'error'; error: Error }
function handleRequest(state: RequestState) {
if (state.status === 'success') {
// TypeScript knows state.data exists
console.log(state.data)
}
}
// Type guard
function isEmail(value: unknown): value is string {
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}// ❌ AVOID - any bypasses type checks
function parseData(data: any) {
return data.value
}
// ✅ CORRECT - unknown with type guard
function parseData(data: unknown): string {
if (typeof data === 'object' && data !== null && 'value' in data) {
return String(data.value)
}
throw new Error('Invalid data')
}
// ✅ CORRECT - Record for unknown object shapes
function processConfig(config: Record<string, unknown>) {
// Type-safe access to object properties
const name = typeof config.name === 'string' ? config.name : 'default'
return name
}
// ✅ ACCEPTABLE - Record<string, any> when structure is truly unknown
// Use sparingly, prefer Record<string, unknown> for stricter type safety
function processApiResponse(response: Record<string, any>) {
// When you need flexibility but know it's an object
return response
}Guidelines:
- ✅ Use
unknown- For values of unknown type (requires type guards) - ✅ Use
Record<string, unknown>- For objects with unknown shape (stricter) ⚠️ UseRecord<string, any>- Only when you need flexibility and know it's an object- ❌ Never use
any- Bypasses all type safety
When working with strongly typed objects, use generics to preserve type information:
// ✅ CORRECT - Generic preserves type
function getObjectValue<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key]
}
// Usage - return type is automatically inferred
interface User {
name: string
age: number
}
const user: User = { name: 'Alice', age: 30 }
const userName = getObjectValue(user, 'name') // Type: string
const userAge = getObjectValue(user, 'age') // Type: number
// ✅ CORRECT - Generic with constraints
function mapObject<T extends object, R>(
obj: T,
mapper: (value: T[keyof T], key: keyof T) => R
): R[] {
return Object.entries(obj).map(([key, value]) =>
mapper(value as T[keyof T], key as keyof T)
)
}
// ❌ AVOID - Loses type information
function getObjectValue(obj: object, key: string): unknown {
return (obj as any)[key] // No type safety
}Benefits:
- ✅ Type preservation - Return types inferred from input types
- ✅ IntelliSense support - Better autocomplete
- ✅ Compile-time safety - Catch errors before runtime
Use arrow functions for React components:
// Good - Arrow function export (standard pattern)
export const ProfileCard = ({ name, address }: ProfileCardProps) => {
return (
<div>
<h2>{name}</h2>
<p>{address}</p>
</div>
)
}
// Avoid - Function declaration for components (use for utilities only)
export function ProfileCard({ name, address }: ProfileCardProps) {
return <div>...</div>
}
// Avoid - Function expression
export const ProfileCard = function({ name, address }: ProfileCardProps) {
return <div>...</div>
}Note: Function declarations (function) are reserved for utility functions and helpers, not React components.
Define props interfaces next to the component:
// Good - Named interface with arrow function
interface ProfileCardProps {
readonly name: string
readonly username: string
readonly onEdit?: () => void
}
export const ProfileCard = ({ name, username, onEdit }: ProfileCardProps) => {
return <div>...</div>
}
// Good - Inline type for simple components
export const Button = ({
children,
variant = 'default',
...props
}: React.ComponentProps<'button'> & { variant?: 'default' | 'outline' }) => {
return <button {...props}>{children}</button>
}
// Good - Destructuring with type annotation
export const NameProfileCard = ({ name }: { name: string }) => {
return <div>{name}</div>
}Use ts-pattern for complex conditional logic over ternaries or && operators:
Performance Note:
ts-patternhas some overhead due to JIT compilation (benchmark details). For simple conditions or hot paths, native conditionals may be faster. Measure if performance is critical (see Performance Guidelines).
import { match } from 'ts-pattern'
import { P } from 'ts-pattern'
// Good - Explicit pattern matching
export const ProfileStatus = ({ status }: { status: ProfileStatus }) => {
return match(status)
.with({ type: 'loading' }, () => <LoadingSpinner />)
.with({ type: 'error', error: P.select() }, (error) => (
<ErrorMessage error={error} />
))
.with({ type: 'success', data: P.select() }, (data) => (
<ProfileCard profile={data} />
))
.exhaustive()
}
// Avoid - Nested ternaries
export const ProfileStatus = ({ status }) => {
return status.type === 'loading'
? <LoadingSpinner />
: status.type === 'error'
? <ErrorMessage error={status.error} />
: <ProfileCard profile={status.data} />
}
// Avoid - && operators with potential falsy bugs
export const ShowCount = ({ count }) => {
return count && <div>Count: {count}</div> // Breaks if count is 0
}
// Good - Explicit nullish check
export const ShowCount = ({ count }: { count: number | null }) => {
return match({ count })
.with({ count: P.not(P.nullish) }, ({ count }) => (
<div>Count: {count}</div>
))
.otherwise(() => null)
}Keep component bodies clean by extracting static values:
// Avoid - Recreated on every render
export const SettingsForm = () => {
const MAX_FILE_SIZE = 10_485_760
const config = {
maxSize: MAX_FILE_SIZE,
allowedTypes: ['image/png', 'image/jpeg'],
}
const validateFile = (file: File) => {
// validation logic
}
return <form>...</form>
}
// Good - Extracted outside component
const MAX_FILE_SIZE = 10_485_760
const UPLOAD_CONFIG = {
maxSize: MAX_FILE_SIZE,
allowedTypes: ['image/png', 'image/jpeg'],
} as const
function validateFileUpload(
file: File,
maxSize: number
): boolean {
return file.size <= maxSize
}
export const SettingsForm = () => {
return <form>...</form>
}Always use TanStack Query for data fetching - never fetch data in useEffect.
// ❌ NEVER DO THIS - Data fetching in useEffect
export const ProfilePage = ({ name }: { name: string }) => {
const [profile, setProfile] = useState<Profile | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
async function fetchProfile() {
setLoading(true)
try {
const data = await getProfile(name)
if (!cancelled) {
setProfile(data)
setError(null)
}
} catch (e) {
if (!cancelled) setError(e as Error)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchProfile()
return () => { cancelled = true }
}, [name])
if (loading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
return <ProfileView profile={profile} />
}
// ✅ ALWAYS DO THIS - Use TanStack Query
export const ProfilePage = ({ name }: { name: string }) => {
const { data: profile, isLoading, error } = useQuery(getProfileQueryOptions(name))
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!profile) return null
return <ProfileView profile={profile} />
}Why useEffect is problematic for data fetching:
- ❌ Waterfalls - Dependencies cause sequential fetches
- ❌ Race conditions - React 18+ concurrent mode can race effects
- ❌ No caching - Same data fetched multiple times
- ❌ Messy error handling - Manual state management
- ❌ No retry logic - Must implement yourself
- ❌ No stale data - Can't show stale while revalidating
TanStack Query solves all of these - use it for ALL data fetching.
Default rule: In components, extract useEffect into a named custom hook to document intent and keep components readable.
Valid use cases for useEffect:
- DOM manipulation (focus, scroll, resize observers)
- Setting up/tearing down subscriptions
- Syncing with external systems (localStorage, WebSocket)
- Side effects triggered by prop/state changes
// ❌ AVOID: Naked useEffect in component
export const ModalComponent = ({ isOpen }: { isOpen: boolean }) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
return <div>...</div>
}
// ✅ CORRECT: Extract into named hook
function useLockBodyScroll(isLocked: boolean) {
useEffect(() => {
if (isLocked) {
document.body.style.overflow = 'hidden'
}
return () => {
document.body.style.overflow = ''
}
}, [isLocked])
}
export const ModalComponent = ({ isOpen }: { isOpen: boolean }) => {
useLockBodyScroll(isOpen)
return <div>...</div>
}
export const ProfilePage = ({ name }: { name: string }) => {
const [profile, setProfile] = useState<Profile | null>(null)
useProfileData(name, setProfile)
return <div>...</div>
}Why extract effects?
- Hook name documents the purpose
- Component body stays focused on rendering
- Easier to reuse across components
Allowed exceptions (must stay trivial):
- ≤ 5 lines of code
- No async logic
- No domain or multi-branch logic
(trivial guards likeif (!ref.current) returnare fine) - No external dependencies
// ✅ Acceptable: Simple DOM sync effect
export const AutoFocusInput = () => {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
ref.current?.focus()
}, [])
return <input ref={ref} />
}If an effect grows beyond these constraints, extract it immediately.
Note: This rule applies to components only. Inside custom hooks, useEffect is expected and does not need further extraction—that's where effects belong.
Split components based on complexity, not arbitrary line counts.
When to split:
- ✅ Component has multiple concerns (data fetching + rendering + form logic)
- ✅ Logic is reusable across multiple parents
- ✅ Component is hard to understand due to complexity (not size)
- ✅ Different parts change for different reasons
When NOT to split:
- ❌ Component is mostly static JSX (navigation would take longer than reading)
- ❌ Split components are 50% type definitions and props drilling
- ❌ You're only splitting to hit a line count target
// ❌ Over-split - Harder to follow, mostly definitions
const ProfileHeader = ({ name, avatar }: ProfileHeaderProps) => {
return (
<header className="flex items-center gap-4">
<Avatar src={avatar} />
<h1>{name}</h1>
</header>
)
}
// ✅ Good - Split when there's actual complexity
const ProfileRecordsEditor = ({ records, onChange }: Props) => {
const [editMode, setEditMode] = useState(false)
const { writeContractAsync } = useWriteContract()
const handleSave = async () => {
// 30+ lines of validation, encoding, transaction logic
}
return editMode ? <Editor /> : <Display />
}
const ProfilePage = ({ name }: ProfilePageProps) => {
const { data: profile } = useQuery(getProfileQueryOptions(name))
return (
<div>
{/* ✅ Static header stays inline - easy to read */}
<header className="flex items-center gap-4">
<Avatar src={profile.avatar} />
<h1>{profile.name}</h1>
</header>
{/* ✅ Complex editor extracted - manages its own state and logic */}
<ProfileRecordsEditor
records={profile.records}
onChange={handleUpdate}
/>
</div>
)
}Rule of thumb: If finding the split component takes longer than scanning the original, don't split it.
import { useQuery, queryOptions } from '@tanstack/react-query'
// 1. Create the data fetching function
async function getProfile(name: string): Promise<Profile> {
const response = await fetch(`/api/profiles/${name}`)
if (!response.ok) throw new Error('Failed to fetch profile')
return response.json()
}
// 2. Create query options factory
export const getProfileQueryOptions = (name: string) =>
queryOptions({
queryKey: ['profile', { name }],
queryFn: () => getProfile(name),
})Why no custom hook wrapper?
- ✅ Works with all query hooks -
useQuery,useSuspenseQuery,useQueries - ✅ Preloading in router loaders - Can use options directly in
loader - ✅ Customizable per use case - Add
staleTime,enabled, etc. in component - ✅ Simpler types - No need to handle custom option overrides
Use object-based params for type-safe, invalidation-friendly keys:
// ✅ Good - Object params, easy to invalidate
queryKey: ['profile', { name }]
// Invalidate ALL profile queries
queryClient.invalidateQueries({ queryKey: ['profile'] })
// Invalidate specific profile
queryClient.invalidateQueries({
queryKey: ['profile', { name: 'alice' }]
})Query Key Best Practices:
- ✅ Object for params - Enables partial matching for invalidation
- ✅ Named properties -
{ name }not justname - ✅ Consider scope-based keys - Group related queries for easier invalidation
// ✅ Good - Object params, invalidation-friendly
queryKey: ['channels', { name: 'alice', includeBlocks: true }]
// ❌ Avoid - Positional params, hard to invalidate partially
queryKey: ['channels', name, includeBlocks]
// ❌ Avoid - Reversed order
queryKey: [name, 'channels']Standard pattern:
// ✅ Basic usage
export const ProfileCard = ({ name }: { name: string }) => {
const { data, isLoading, error } = useQuery(getProfileQueryOptions(name))
if (isLoading) return <LoadingMessage />
if (error) {
const message = error.message || 'Could not load profile'
return <ErrorMessage title="Profile unavailable" description={message} />
}
if (!data) {
return <ErrorMessage title="Profile unavailable" description="No data returned" />
}
return <ProfileDetails profile={data} />
}
// ✅ With custom options
export const LiveProfileCard = ({ name }: { name: string }) => {
const { data, isLoading, error } = useQuery({
...getProfileQueryOptions(name),
staleTime: 5000, // Refetch every 5s
refetchInterval: 5000,
})
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage title="Error" description={error.message} />
if (!data) return null
return <ProfileDetails records={data.records} />
}
// ✅ With suspense
export const SuspenseProfileCard = ({ name }: { name: string }) => {
const { data, error } = useSuspenseQuery(getProfileQueryOptions(name))
// No loading state needed - suspense handles it
if (error) return <ErrorMessage error={error} />
if (!data) return null
return <ProfileDetails records={data.records} />
}
// ✅ Multiple queries with useQueries
export const MultiProfileCard = ({ names }: { names: string[] }) => {
const queries = useQueries({
queries: names.map(name => getProfileQueryOptions(name)),
})
return (
<div>
{queries.map((q, i) => {
if (q.isLoading) return <LoadingSpinner key={names[i]} />
if (q.error) return <ErrorMessage key={names[i]} error={q.error} />
if (!q.data) return null
return <ProfileCard key={names[i]} data={q.data} />
})}
</div>
)
}
// ✅ Route component receives typed params from TanStack Router
export const Route = createFileRoute('/collections/$name/documents')({
component: RecordsPage,
})
function RecordsPage() {
const { name } = Route.useParams()
return <ProfilePage name={name} />
}Key points:
- ✅ Always check
isLoading,error, and!databefore usingdata - ✅ Access error details via
error.message
Don't run dependent queries in the same component - move them to separate components.
// ❌ AVOID - Waterfall queries in same component
export const ProfilePage = ({ name }: { name: string }) => {
const { data: profile, isLoading: isLoadingProfile, error: profileError } =
useQuery(getProfileQueryOptions(name))
// This waits for profile to load before fetching
const { data: channels, isLoading: isLoadingChannels, error: channelsError } =
useQuery({
...getChannelsQueryOptions(profile?.id),
enabled: !!profile?.id, // Dependent query
})
// Now you have 4 states to manage: 2 loading, 2 errors
if (isLoadingProfile || isLoadingChannels) return <LoadingSpinner />
if (profileError || channelsError) return <ErrorMessage />
return <div>{/* Complex state management */}</div>
}
// ✅ CORRECT - Split into separate components
export const ProfilePage = ({ name }: { name: string }) => {
const { data: profile, isLoading, error } = useQuery(getProfileQueryOptions(name))
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!profile) return null
// Pass profile to child component that handles channels
return <ProfileWithChannels profile={profile} />
}
export const ProfileWithChannels = ({ profile }: { profile: Profile }) => {
const { data: channels, isLoading, error } = useQuery(
getChannelsQueryOptions(profile.id)
)
if (isLoading) return <LoadingSpinner />
if (error) return <ErrorMessage error={error} />
if (!channels) return null
return <ChannelsView profile={profile} channels={channels} />
}Benefits of splitting:
- ✅ Clearer state management - One query per component
- ✅ Better loading UX - Show profile while records load
- ✅ Easier error handling - Each error is specific to its data
- ✅ Better composability - Components are more reusable
Don't group error or loading states - handle each query separately.
// ❌ AVOID - Grouped loading/error states
export const DashboardPage = () => {
const { data: profile, isLoading: isLoadingProfile, error: profileError } =
useQuery(getProfileQueryOptions())
const { data: names, isLoading: isLoadingNames, error: namesError } =
useQuery(getNamesQueryOptions())
// This hides which data is loading/erroring
if (isLoadingProfile || isLoadingNames) return <LoadingSpinner />
if (profileError || namesError) return <ErrorMessage />
return <Dashboard profile={profile} names={names} />
}
// ✅ CORRECT - Handle each query independently
export const DashboardPage = () => {
const { data: profile, isLoading: isLoadingProfile, error: profileError } =
useQuery(getProfileQueryOptions())
const { data: names, isLoading: isLoadingNames, error: namesError } =
useQuery(getNamesQueryOptions())
return (
<div>
{/* Show profile section state independently */}
{isLoadingProfile ? (
<LoadingSpinner />
) : profileError ? (
<ErrorMessage error={profileError} />
) : (
<ProfileSection profile={profile} />
)}
{/* Show names section state independently */}
{isLoadingNames ? (
<LoadingSpinner />
) : namesError ? (
<ErrorMessage error={namesError} />
) : (
<NamesSection names={names} />
)}
</div>
)
}Why this matters:
- ✅ Different errors mean different things - Profile error ≠ names error
- ✅ Show partial data - Display profile even if names fails
- ✅ Better UX - User sees some content immediately
- ✅ Specific error messages - Tell user exactly what failed
For multiple parallel queries, use useQueries - cleaner and more concise.
// ❌ AVOID - Multiple parallel useQuery calls
export const MultiProfilePage = ({ names }: { names: string[] }) => {
const profile1 = useQuery(getProfileQueryOptions(names[0]))
const profile2 = useQuery(getProfileQueryOptions(names[1]))
const profile3 = useQuery(getProfileQueryOptions(names[2]))
// Verbose state management
const isLoading = profile1.isLoading || profile2.isLoading || profile3.isLoading
const errors = [profile1.error, profile2.error, profile3.error].filter(Boolean)
if (isLoading) return <LoadingSpinner />
if (errors.length > 0) return <ErrorMessage />
return <div>...</div>
}
// ✅ CORRECT - Use useQueries
export const MultiProfilePage = ({ names }: { names: string[] }) => {
const queries = useQueries({
queries: names.map(name => getProfileQueryOptions(name)),
})
return (
<div>
{queries.map((query, i) => {
if (query.isLoading) return <LoadingSpinner key={names[i]} />
if (query.error) return <ErrorMessage key={names[i]} error={query.error} />
if (!query.data) return null
return <ProfileCard key={names[i]} data={query.data} />
})}
</div>
)
}
// ✅ ALSO GOOD - Aggregate states when appropriate
export const MultiProfilePage = ({ names }: { names: string[] }) => {
const queries = useQueries({
queries: names.map(name => getProfileQueryOptions(name)),
})
const isLoading = queries.some(q => q.isLoading)
const errors = queries.filter(q => q.error).map(q => q.error)
const allData = queries.every(q => q.data) ? queries.map(q => q.data) : null
if (isLoading) return <LoadingSpinner />
if (errors.length > 0) return <ErrorList errors={errors} />
if (!allData) return null
return <ProfileList profiles={allData} />
}Benefits:
- ✅ Less verbose - One hook instead of many
- ✅ Dynamic - Works with variable-length arrays
- ✅ Type-safe - Proper TypeScript inference
- ✅ Consistent pattern - Standard way to handle parallel queries
features/
└── profile/
├── hooks/
│ ├── use-profile.ts # getProfile + getProfileQueryOptions
│ ├── use-channels.ts # getChannels + getChannelsQueryOptions
│ └── use-blocks.ts # getBlocks + getBlocksQueryOptions
└── components/
└── ProfileCard.tsx # useQuery(getProfileQueryOptions(...))
File naming: Keep use*.ts convention even though they export query options, not hooks. This maintains consistency and groups query-related code in the hooks/ folder.
Build flexible components through composition:
// Good - Composition
export const ProfilePage = ({ name }: { name: string }) => {
const { data: profile } = useQuery(getProfileQueryOptions(name))
return (
<Card>
<CardHeader>
<Avatar src={profile.avatar} />
<h1>{profile.name}</h1>
</CardHeader>
<CardBody>
<ProfileRecords records={profile.records} />
</CardBody>
<CardFooter>
<Button onClick={handleEdit}>Edit</Button>
</CardFooter>
</Card>
)
}
// Avoid - Configuration props
export const ProfileCard = ({
profile,
showAvatar,
showRecords,
showEditButton,
onEdit,
}: ProfileCardProps) => {
return (
<div>
{showAvatar && <Avatar />}
{showRecords && <Records />}
{showEditButton && <Button onClick={onEdit} />}
</div>
)
}import { Dialog } from '@base-ui/react/dialog'
// Good - Compose Base UI primitives with custom styling
export const EditProfileDialog = ({ children }: { children: React.ReactNode }) => {
return (
<Dialog.Root>
<Dialog.Trigger>Edit Profile</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 bg-black/50" />
<Dialog.Popup className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Dialog.Title>Edit Profile</Dialog.Title>
{children}
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
)
}// Good - Render prop for flexibility
interface DataTableProps<T> {
data: readonly T[]
renderRow: (item: T) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
export const DataTable = <T,>({ data, renderRow, renderEmpty }: DataTableProps<T>) => {
if (data.length === 0) {
return renderEmpty?.() ?? <p>No data</p>
}
return (
<table>
<tbody>
{data.map((item, index) => (
<tr key={index}>{renderRow(item)}</tr>
))}
</tbody>
</table>
)
}
// Usage
<DataTable
data={profiles}
renderRow={(profile) => (
<>
<td>{profile.name}</td>
<td>{profile.address}</td>
</>
)}
/>React 19 and our stack are fast by default. Optimize only when proven necessary.
- Measure before optimizing - Use React DevTools Profiler to identify actual bottlenecks
- Favor clarity over micro-optimizations - Premature optimization obscures intent
- Trust React's defaults - React 19's concurrent features handle most scenarios
- Optimize for bundle size first - Smaller bundles → faster initial load
// ❌ Premature optimization - No benefit
export const Button = ({ label }: { label: string }) => {
const text = useMemo(() => label.toUpperCase(), [label]) // Unnecessary
return <button>{text}</button>
}
// ✅ Good - Expensive calculation
export const NameValidator = ({ name }: { name: string }) => {
const isValid = useMemo(() => {
// Expensive regex or normalization
return validateENSName(name) // Only recompute when name changes
}, [name])
return <div>{isValid ? '✓' : '✗'}</div>
}
// ✅ Good - Referential stability for dependencies
export const UserList = () => {
const filters = useMemo(() => ({ active: true, verified: true }), [])
const users = useQuery(getUsersQueryOptions(filters)) // Prevents refetch on re-render
return <div>...</div>
}// ❌ Unnecessary - Simple inline handler
<button onClick={() => setCount(c => c + 1)}>Increment</button>
// ✅ Good - Passed to memoized child
const MemoizedChild = memo(Child)
export const Parent = () => {
const handleSave = useCallback((data: FormData) => {
// Save logic
}, [])
return <MemoizedChild onSave={handleSave} />
}Before optimizing, check these first:
- Code splitting - Use route-based code splitting with TanStack Router and Vite
- Bundle analysis - Remove unused dependencies (
pnpm why <package>) - Image optimization - Use WebP, lazy loading, proper sizing
- Query management - Set appropriate
staleTimeandgcTimefor TanStack Query - Avoid prop drilling - Use composition instead of passing props through many layers
// Use React DevTools Profiler
import { Profiler } from 'react'
<Profiler id="UserList" onRender={(id, phase, actualDuration) => {
console.log(`${id} (${phase}) took ${actualDuration}ms`)
}}>
<UserList />
</Profiler>Key metrics:
- Initial render - Should be < 100ms for most components
- Re-render time - Should be < 16ms (60fps)
- Bundle size - Keep route chunks under 200KB (gzipped)
Error boundaries catch rendering errors that escape normal error handling.
import { ErrorBoundary } from 'react-error-boundary'
function NameError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div role="alert" className="p-4">
<h2>Something went wrong</h2>
<pre className="text-sm">{error.message}</pre>
<button onClick={reset}>Try again</button>
</div>
)
}
// Or use react-error-boundary for component-level boundaries
// within a page or layoutExpected errors ≠ Rendering errors (Error Boundaries)
| Error Type | Handling | Example |
|---|---|---|
| Data fetching errors | TanStack Query error state | API failure, network error |
| Business logic errors | try-catch + early returns | Invalid input, auth failure |
| Rendering errors | Error Boundary | Component crash, ref error |
| Unexpected errors | Error Boundary + logging | Third-party lib bugs |
import { type FallbackProps } from 'react-error-boundary'
const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
return (
<div role="alert" className="p-4">
<h2>Something went wrong</h2>
<pre className="text-sm">{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
// Use at route or feature level
export const ProfilePage = () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<ProfileContent />
</ErrorBoundary>
)
}- ✅ Use route-level boundaries - Isolate errors to specific routes
- ✅ Log errors - Send to monitoring service (Sentry, LogRocket)
- ✅ Provide recovery - Give users a way to retry or navigate away
- ✅ Never swallow errors - Always log or display them
- ❌ Don't use for control flow - Use try-catch for expected errors
- ❌ Don't catch all errors globally - Granular boundaries are better
// Good - Utility classes
export const Button = ({ children }: { children: React.ReactNode }) => {
return (
<button className="rounded-sm bg-primary px-4 py-2 text-white hover:bg-primary/90">
{children}
</button>
)
}Use class-variance-authority for type-safe variants:
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-sm font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-white hover:bg-primary/90',
outline: 'border border-input bg-background hover:bg-accent',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3',
lg: 'h-10 px-6',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
interface ButtonProps
extends React.ComponentProps<'button'>,
VariantProps<typeof buttonVariants> {}
export const Button = ({ className, variant, size, ...props }: ButtonProps) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}Note: For complex multi-part components (e.g., Card with separate Header, Body, Footer styles), consider tailwind-variants which extends CVA with slots and built-in class merging. For single-part components, CVA +
cn()is sufficient.
Pick one approach (clsx/cn or tw/twm) and use it consistently across the codebase.
clsx merges class names conditionally. cn wraps clsx with tailwind-merge to resolve conflicting Tailwind utilities (e.g., bg-red-500 vs bg-blue-500, p-2 vs p-4).
import { cn } from '@/lib/utils'
import clsx from 'clsx'
// Use clsx when you just need to merge classes without Tailwind conflict resolution
const simpleClasses = clsx('text-sm', isActive && 'font-bold')
// Use cn when you need Tailwind conflict resolution
export const Card = ({ isActive, className }: CardProps) => {
return (
<div
className={cn(
'rounded-lg border p-4',
isActive && 'border-primary bg-primary/5',
className
)}
>
...
</div>
)
}When to use clsx vs cn:
- Use
clsxwhen you just need to merge classes and don't have conflicting Tailwind utilities - Use
cnwhen composing classes that might conflict (e.g., different padding/margin values, different background colors)
tw and twm are custom utilities that provide a single API for all class name use cases. They handle single strings, template literals, and clsx-style function calls, choosing the most efficient method based on input.
import { tw, twm } from '@/utils/tailwind'
// tw: Single string (returns directly, no clsx overhead)
<Button className={tw`px-4 py-2`} />
// tw: Tagged template with interpolations
<Button className={tw`px-4 py-2 ${isActive ? 'bg-primary' : ''}`} />
// tw: Function call form (clsx-compatible)
<Button className={tw('px-4 py-2', isActive && 'bg-primary')} />
// twm: Same as tw but with tailwind-merge for conflict resolution
<Button className={twm('px-4 py-2', isActive && 'bg-primary')} />About tw and twm:
twis a unified utility that handles all use cases: single strings, template literals, andclsxfunction syntax- Performance-optimized: Only invokes
clsxwhen there are multiple inputs; single string inputs are returned directly - Supports all
clsxfeatures: strings, arrays, objects, conditionals, nested structures twmaddstwMergefor Tailwind-aware conflict resolution (equivalent tocnbut with the same unified API)- Prefer
twby default for the lightest helper; usetwmwhen you need conflict resolution
Note: Autocomplete is the same for clsx, cn, tw, and twm - they're all configured identically in the Tailwind config. The benefit of tw/twm is the unified API that enables Tailwind autocomplete even for simple string assignments where you'd normally just use a plain string:
// No intellisense
const myVar = "text-red-500"
// Intellisense enabled from var name
const className = "text-red-500"
// Intellisense manually enabled (works everywhere)
const myVar = tw`text-red-500`Benefits of consistency:
- ✅ One import - Team knows where to look
- ✅ Better IDE support - Configure once
- ✅ Easier onboarding - One pattern to learn
- ✅ Biome integration -
useSortedClassesworks withtwhelper
Use design system tokens instead of arbitrary values. Only use arbitrary values when justified.
// ❌ AVOID - Arbitrary values break design system
<div className="w-[37px] h-[23px] text-[#3B82F6]" />
// ✅ CORRECT - Use design tokens
<div className="size-9 text-blue-500" />
<div className="w-8 h-6 text-primary" />
// ✅ ACCEPTABLE - When design system doesn't have the value
<div
className="w-[120px]"
/>Why avoid arbitrary values:
- ❌ Breaks consistency - Diverges from design system
- ❌ Hard to maintain - Magic numbers scattered everywhere
- ❌ No type safety - Easy to make typos
- ❌ Larger bundle - Each arbitrary value adds CSS
When arbitrary values are justified:
- ✅ Interfacing with third-party components with fixed dimensions
- ✅ Dynamic values from props/API that can't use tokens
- ✅ One-off exceptions that don't fit the design system
Always ask: "Could this use a design token instead?"
import { CalendarIcon } from 'lucide-react'
// Good - Use Tailwind size classes
<CalendarIcon className="size-4" />
<CalendarIcon className="size-3.5" />
<CalendarIcon className="size-6" />
// Avoid - Don't use props
<CalendarIcon width={16} height={16} />
<CalendarIcon size={16} />The admin UI uses TanStack Router file-based routing in app/src/routes/:
src/routes/
├── __root.tsx # Root providers and outlet
├── index.tsx # /
├── _auth.tsx # Pathless auth layout
├── _auth.login.tsx # /login
├── _auth.setup.tsx # /setup
├── _dashboard.tsx # Pathless dashboard layout
├── _dashboard.overview.tsx # /overview
├── _dashboard.collections.tsx # /collections
├── _dashboard.collections.$name.tsx # /collections/:name layout
├── _dashboard.collections.$name.documents.tsx # /collections/:name/documents
└── _dashboard.collections.$name.documents.$docId.tsx
Use _-prefixed pathless routes for layouts that should not add URL segments, and $ segments for dynamic params:
_dashboard.tsx
_dashboard.collections.$name.tsx
_dashboard.collections.$name.documents.$docId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_dashboard/collections/$name/search')({
component: SearchPage,
})
function SearchPage() {
const { name } = Route.useParams()
return <CollectionSearch name={name} />
}import { Outlet, createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_dashboard/collections/$name')({
component: CollectionLayout,
})
function CollectionLayout() {
const { name } = Route.useParams()
return (
<div>
<CollectionHeader name={name} />
<Outlet />
</div>
)
}import { Link, useNavigate } from '@tanstack/react-router'
export const Navigation = () => {
const navigate = useNavigate()
return (
<nav>
<Link to="/collections/$name" params={{ name: 'docs' }}>
View collection
</Link>
<button
type="button"
onClick={() => {
navigate({ to: '/collections/$name/documents', params: { name: 'docs' } })
}}
>
Go to documents
</button>
</nav>
)
}Use WCAG 2 guidelines wherever possible (prefer WCAG 2.2).
Quick references:
- WCAG overview: https://www.w3.org/WAI/standards-guidelines/wcag/
- How to Meet WCAG 2.2 (Quick Reference): https://www.w3.org/WAI/WCAG22/quickref/
Never remove focus styles without a clear replacement. Prefer :focus-visible so mouse users don’t get distracting rings.
// Good - visible keyboard focus ring
<button
type="button"
className="rounded-sm px-3 py-2 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Save
</button>
// Avoid - removing outlines with no replacement
<button type="button" className="outline-none">
Save
</button>Use semantic HTML elements:
// Good
<nav>
<ul>
<li><a href="/">Home</a></li>
</ul>
</nav>
<main>
<article>
<h1>Title</h1>
<p>Content</p>
</article>
</main>
// Avoid
<div className="nav">
<div className="nav-list">
<div><span onClick={goHome}>Home</span></div>
</div>
</div>// Good - Accessible button
<button
type="button"
aria-label="Close dialog"
aria-pressed={isPressed}
>
<XIcon className="size-4" />
</button>
// Good - Accessible form
<form>
<label htmlFor="name-input">
ENS Name
</label>
<input
id="name-input"
type="text"
aria-describedby="name-help"
aria-invalid={hasError}
/>
<p id="name-help">Enter your ENS name</p>
</form>Ensure all interactive elements are keyboard accessible:
export const MenuItem = ({ onClick }: { onClick: () => void }) => {
return (
<button
type="button"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}}
>
Menu Item
</button>
)
}Use Explaining Variables - Extract complex expressions into named variables for clarity:
// Good
const isEligible = user.age >= 18 && user.hasVerifiedEmail && !user.isBanned
if (isEligible) { /* ... */ }
// Avoid
if (user.age >= 18 && user.hasVerifiedEmail && !user.isBanned) { /* ... */ }Avoid Magic Values - Replace magic numbers and strings with named constants:
// Good
const MAX_UPLOAD_SIZE = 10_485_760
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg'] as const
// Avoid
if (file.size > 10485760) { /* ... */ }Prefer Array Methods (🟢 Guideline) - Use map, filter, reduce for transformations:
// Good - Declarative transformations
const activeUsers = users.filter(user => user.isActive)
const userNames = users.map(user => user.name)
const totalScore = users.reduce((sum, user) => sum + user.score, 0)
// Also Good - Loop with early exit
function findFirstExpired(items: Expirable[]): Expirable | null {
for (const item of items) {
if (item.expiresAt < Date.now()) return item
}
return null
}Immutable Transformations - Create new values instead of mutating:
// Good
const addItem = <T,>(items: readonly T[], newItem: T) => [...items, newItem]
const updateItem = <T extends { id: string }>(items: readonly T[], id: string, updates: Partial<T>) =>
items.map(item => item.id === id ? { ...item, ...updates } : item)
// Avoid mutation
items.push(newItem) // ❌Biome is a fast, unified toolchain for formatting and linting. It replaces ESLint and Prettier with a single tool:
- Fast: Written in Rust, 10-100x faster than ESLint
- Unified: One tool for formatting and linting
- Zero config: Works out of the box with sensible defaults
- Import sorting: Built-in
organizeImportsfeature - IDE support: First-class support in VSCode/Cursor
Project uses Biome for formatting and linting. See biome.jsonc in the root.
Key Settings:
- Formatter: 2 spaces, double quotes, semicolons always
- Linter: All recommended rules + custom a11y rules
- Auto-organize imports: Enabled
- Indentation: 2 spaces
- Quotes: Double quotes
- Semicolons: Always
- Import Organization: Automatic (type imports → external → internal)
// Format example
const name = "alice";
const config = { timeout: 5000, retries: 3 };
// Imports auto-organized
import type { Profile } from "@bigrag/db";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";| Rule | Purpose | Fix |
|---|---|---|
noExplicitAny |
Avoid any types |
Use unknown + type guards |
noNonNullAssertion |
Avoid ! operator |
Use ?. or type guards |
useButtonType |
Explicit button types | Add type="button" |
useKeyWithClickEvents |
Keyboard accessibility | Add onKeyDown or use <button> |
noNestedComponentDefinitions |
Don't nest components | Define outside parent |
noForEach |
Prefer for...of loops over Array.forEach |
Use for...of |
noUselessElse |
Avoid unnecessary else blocks after returns | Prefer early returns |
noUnusedTemplateLiteral |
Avoid template literals without interpolation | Use regular strings |
noNegationElse |
Avoid negated conditions with else branches | Invert the condition |
pnpm biome check --write . # Format + lint + fix
pnpm biome format --write . # Format onlyInstall Biome extension and set as default formatter. Enable format-on-save and organize imports.
Use sparingly for legitimate cases:
// Ignore specific line
// biome-ignore lint/suspicious/noExplicitAny: Third-party types unavailable
const data: any = externalLibrary.getData()When to ignore: Third-party type issues, generated files, documented edge cases
When NOT to ignore: To avoid fixing real issues, skip proper typing, suppress a11y warnings
- Keep React thin - Components for UI, not business logic (🔴 Must)
- Co-locate code - Keep files next to their usage (🟢 Guideline)
- Be explicit - Make data flow and dependencies clear (🟡 Default)
- Separation of concerns - Business logic separate from presentation (🔴 Must)
- File naming conventions - Use
*.handlers.ts,*.machine.ts,*.mock.ts(🟡 Default) - Mock data policy - All mocks in
*.mock.tsfiles, gated by dev flags (🟡 Default)
- Type everything - Leverage TypeScript's strict mode (🔴 Must)
- No
anytypes - UseunknownorRecord<string, unknown>for type-safe handling (🔴 Must) - Use generics - Preserve type information in utility functions (🟡 Default)
- Use readonly - Enforce immutability at type level (🟡 Default)
- Discriminated unions - For state management and variants (🟡 Default)
- Pure functions - Same input → same output, no side effects (🟡 Default)
- Immutability - Transform data, don't mutate (🔴 Must)
- Prefer array methods - Use
map,filter,reduce(🟢 Guideline) - Composition - Build complex operations from simple ones (🟡 Default)
- Never useEffect for data fetching - Always use TanStack Query (🔴 Must)
- Extract effects - Extract
useEffectin components to custom hooks (🟡 Default, ≤5 lines OK) - Pattern match - Use ts-pattern over conditionals (🟡 Default)
- Custom hooks for APIs - Only for DOM/framework APIs, not business logic (🟡 Default)
- Component composition - Build flexible UIs with composition (🟢 Guideline)
- Avoid prop drilling - Use hooks/context for app state, props for local data (🟡 Default)
- React state for UI - Forms, toggles, simple caching
- XState for workflows - Complex multi-step flows
- TanStack Query for async data - Don't reinvent loading/error states with useState (🟡 Default)
- Avoid query waterfalls - Split dependent queries into separate components (🟡 Default)
- Handle query states independently - Don't group loading/error states with
||(🟡 Default) - Use useQueries for parallel queries - More concise than multiple useQuery calls (🟡 Default)
- Object-based query keys - Use singular object for params to enable partial invalidation (🟡 Default)
- Measure before optimizing - Use React DevTools Profiler (🟢 Guideline)
- Avoid premature memoization - Only memoize when proven necessary (🟢 Guideline)
- Route-level error boundaries - Catch rendering errors (🟡 Default)
- Choose one class helper - Use
cnortwconsistently (🟡 Default) - Avoid arbitrary values - Use design tokens, not
w-[37px](🟡 Default)
- Use Biome - Format and lint with one tool (🔴 Must)
- Double quotes - For string literals (🟢 Guideline)
- 2-space indentation - Consistent formatting (🟢 Guideline)
- Organize imports - Let Biome handle import sorting (🟢 Guideline)
Ask yourself these questions when writing code:
- Can this logic work outside React? → Extract to helper function
- Does this have side effects? → Use
useEffectin custom hook
- Is this async data fetching? → Use TanStack Query, not useState
- Is this UI state or business state? → React state vs XState
- Does this need to be cached? → Use TanStack Query
- Is this a multi-step flow? → Use XState machine
- Creating a query key? → Use object params for easy invalidation
- Am I using
any? → Useunknownor proper types - Can this fail? → Use try-catch or throw
- Are there multiple variants? → Use discriminated union
- Am I hiding complexity? → Make it explicit
- Is the data flow clear? → Add types and explaining variables
- Will new developers understand this? → Document intent with names
- Can this operation fail? → Use try-catch or custom error classes
- Do I need multiple error types? → Use custom error classes with discriminated unions
- Am I composing multiple operations? → Chain async/await with try-catch
- Can keyboard users access this? → Add keyboard handlers
- Is this element semantic? → Use proper HTML elements
- Are labels associated? → Connect labels to inputs
- Business logic in components - Extract to helpers
- Data fetching in useEffect - Use TanStack Query
- Naked
useEffectin components - Create custom hooks - Query waterfalls in one component - Split into separate components
- Grouping query loading/error states - Handle independently
- Using
anytype - UseunknownorRecord<string, unknown> - Losing type information - Use generics to preserve types
- Mutating data - Use immutable transformations
- Swallowing errors silently - Always handle or rethrow
- Non-null assertions (
!) - Use type guards or optional chaining - Complex nested conditionals - Use pattern matching
- Magic numbers and strings - Extract to named constants
- Direct dependency imports - Use dependency injection
- Pure functions - Explicit inputs and outputs
- Discriminated unions - For type-safe state management
- Custom error classes - For typed error handling
- Pattern matching - For conditional logic
- Component composition - Build flexible UIs
- Explaining variables - Clarify complex expressions
- readonly modifiers - Enforce immutability
- Custom error classes - Typed errors for pattern matching
- Dependency injection - Pass dependencies as parameters
Rules for the api/ codebase. Python 3.12+, FastAPI, asyncpg, Pydantic v2.
Every Python file must start with future annotations:
from __future__ import annotationsPackage boundary modules that intentionally re-export symbols define __all__:
__all__ = ["EventBus", "IngestionEvent", "event_bus"]Never use print() in production code. Use structlog:
from bigrag.logging import get_logger
logger = get_logger("bigrag.services.queue")
# Structured key-value pairs, not f-strings in logger calls
logger.info("job complete", job_id=job_id, chunks=total, elapsed=round(elapsed, 2))| Element | Convention | Example |
|---|---|---|
| Functions, variables | snake_case |
get_collection, total_chunks |
| Classes | PascalCase |
EventBus, IngestionJob |
| Constants | UPPER_SNAKE_CASE |
QUEUE_KEY, DEFAULT_TIMEOUT |
| Private | _leading_underscore |
_cache, _process_job |
| Files | snake_case.py |
event_bus.py, redis_cache.py |
| Pydantic request models | *Request |
CreateCollectionRequest |
| Pydantic response models | *Response |
CollectionResponse |
Public APIs, service boundaries, and non-trivial helpers must have type annotations on parameters and return values. Route handlers must type parameters and declare response_model or response_class; return annotations are preferred when they add information beyond the decorator. Small framework callbacks may use the framework's conventional signature when annotation would add noise:
# GOOD
async def get_or_404(name: str) -> dict:
...
def build_request_kwargs(job: dict) -> dict[str, Any]:
...
# BAD - missing return type
async def get_or_404(name: str):
...Use modern union syntax:
# GOOD
def connect(url: str | None = None) -> None: ...
endpoint_url: str | None
# BAD
from typing import Optional
def connect(url: Optional[str] = None) -> None: ...Use lowercase generics:
# GOOD
items: list[str]
config: dict[str, Any]
ids: set[int]
# BAD
from typing import List, Dict, Set
items: List[str]Order: stdlib, third-party, local. Enforced by ruff I rule.
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
import orjson
from fastapi import APIRouter, Depends, HTTPException
from bigrag.config import settings
from bigrag.database import db
from bigrag.services.event_bus import event_busPrefer deferred imports for heavy or circular dependencies:
async def _process_job(self, worker_id: int, job: IngestionJob) -> None:
# Import at use-site to avoid circular imports and speed up module loading
from bigrag.services.vector_store import vector_storeDomain exceptions live in exceptions.py. Services raise domain exceptions, never HTTPException:
# exceptions.py
class BigRAGError(Exception): ...
class NotFoundError(BigRAGError):
def __init__(self, resource: str, identifier: str): ...
class ConflictError(BigRAGError): ...
class ValidationError(BigRAGError): ...Exception handlers in main.py translate domain errors to HTTP:
# main.py
@app.exception_handler(NotFoundError)
async def not_found_handler(request, exc):
return JSONResponse(status_code=404, content={"detail": str(exc)})Routers may raise HTTPException directly for HTTP-specific concerns (auth, content-type), but services must never import it:
# GOOD - router layer
@router.post("")
async def create_collection(body: CreateCollectionRequest):
existing = await db.fetchrow("SELECT id FROM collections WHERE name = $1", body.name)
if existing:
raise HTTPException(status_code=409, detail="Collection already exists")
# BAD - service layer raising HTTPException
class IngestionQueue:
async def enqueue(self, job):
if depth >= max_depth:
raise HTTPException(status_code=429) # Don't do thisUse Field() with constraints aggressively:
class CreateCollectionRequest(BaseModel):
name: str = Field(min_length=1, max_length=128, pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$")
description: str = ""
chunk_size: int = Field(default=512, ge=64, le=10000)
chunk_overlap: int = Field(default=50, ge=0, le=5000)
@model_validator(mode="after")
def validate_overlap(self):
if self.chunk_overlap >= self.chunk_size:
raise ValueError("chunk_overlap must be less than chunk_size")
return selfKeep request and response models separate. Never reuse the same model for both directions:
# GOOD - separate models
class CreateCollectionRequest(BaseModel):
name: str
description: str = ""
class CollectionResponse(BaseModel):
id: str
name: str
document_count: int
created_at: datetimeUse the Strategy pattern with abstract base classes for pluggable implementations:
class EmbeddingModel(ABC):
@abstractmethod
async def embed(self, texts: list[str], *, input_type: str = "document") -> list[list[float]]: ...
@property
@abstractmethod
def dimension(self) -> int: ...
class OpenAIEmbedding(EmbeddingModel): ...
class CohereEmbedding(EmbeddingModel): ...Use the Factory pattern with caching for model selection:
_models: dict[str, EmbeddingModel] = {}
def get_embedding_model(provider: str, model_name: str, ...) -> EmbeddingModel:
cache_key = f"{provider}:{model_name}:{key_hash}"
if cache_key in _models:
return _models[cache_key]
model = _create_model(provider, model_name, ...)
_models[cache_key] = model
return modelAlways use async def. Always set response_model on JSON endpoints. Streaming, redirect, and file-response endpoints should instead declare the appropriate response class or return a concrete Response. Use Depends() for shared concerns:
@router.get("", response_model=CollectionListResponse)
async def list_collections(
name: str | None = Query(default=None),
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
_: dict = Depends(get_current_user),
):
...Path parameters for resource identity, query parameters for filtering:
# GOOD
GET /v1/collections/{name}/documents?status=ready&limit=50
# BAD
GET /v1/collections?name=mydata&action=list_documents&status=readyUse the FastAPI lifespan context manager for startup/shutdown. Initialize services in order of dependency:
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: connect in dependency order
await db.connect(s.database_url)
await redis_cache.connect(s.redis_url)
await event_bus.connect(s.redis_url)
await ingestion_queue.connect(s.redis_url)
await ingestion_queue.start(db=db)
yield
# Shutdown: close in reverse order
await ingestion_queue.stop()
await event_bus.close()
await redis_cache.close()
await db.close()Store service instances on app.state during lifespan. Access via Request:
# deps.py
def get_db(request: Request) -> Database:
return request.app.state.db
# router
@router.get("/health/ready")
async def readiness(request: Request):
db = request.app.state.dbFor cross-cutting concerns (auth, validation), use Depends():
from bigrag.middleware.auth import get_current_user
@router.post("", response_model=CollectionResponse)
async def create_collection(
body: CreateCollectionRequest,
_: dict = Depends(get_current_user),
):
...Never call blocking I/O inside async def. Use asyncio.to_thread() for blocking libraries:
# GOOD - offload blocking vector-store client call
result = await asyncio.to_thread(self.client.search, collection_name, data, ...)
# GOOD - offload blocking Docling conversion
result = await asyncio.to_thread(_write_and_convert)
# BAD - blocking call in async function
result = self.client.search(collection_name, data, ...) # blocks event loopEvery external call (HTTP, database, Redis, embeddings) must have a timeout:
# GOOD
result = await asyncio.wait_for(model.embed(texts), timeout=30)
resp = await asyncio.wait_for(s3.get_object(Bucket=bucket, Key=key), timeout=120)
# BAD - no timeout, can hang forever
result = await model.embed(texts)Use asyncio.Semaphore to limit concurrent operations:
sem = asyncio.Semaphore(10)
async def _download_and_ingest(obj: dict) -> None:
async with sem:
resp = await s3.get_object(Bucket=bucket, Key=key)
content = await resp["Body"].read()
await _ingest_file(content)Use asyncio.gather() for parallel I/O, but always handle per-task errors:
async def _download(obj: dict) -> None:
try:
...
except asyncio.CancelledError:
raise # let cancellation propagate
except Exception as e:
logger.warning("download failed", key=obj["Key"], error=str(e))
skipped += 1
await asyncio.gather(*(_download(o) for o in objects))Always use parameterized queries. Never interpolate user input into SQL:
# GOOD
row = await db.fetchrow("SELECT * FROM collections WHERE name = $1", name)
# BAD - SQL injection risk
row = await db.fetchrow(f"SELECT * FROM collections WHERE name = '{name}'")Exception: trusted internal values like interval strings can be interpolated with f-strings if validated:
# OK - interval is from a hardcoded list, not user input
interval = "24 hours" # from ["24 hours", "7 days", "30 days"]
await db.fetchrow(f"... AND created_at > now() - interval '{interval}'", collection_name)Use asyncio.create_task() for fire-and-forget work. Always store the task reference:
# GOOD
task = asyncio.create_task(_run_job(job))
_tasks[job_id] = task
task.add_done_callback(lambda _: _tasks.pop(job_id, None))
# BAD - task can be garbage collected
asyncio.create_task(_run_job(job)) # no reference storedAll caches go through redis_cache module. Never use in-memory dicts for caching:
from bigrag.services import redis_cache
# Read
cached = await redis_cache.get("collection:mydata")
# Write with TTL
await redis_cache.set("collection:mydata", data, ttl=30)
# Invalidate
await redis_cache.delete("collection:mydata")
await redis_cache.delete_pattern("analytics:*")Key naming convention: {domain}:{identifier}. Examples:
| Key | TTL | Purpose |
|---|---|---|
collection:{name} |
30s | Collection metadata |
health:embedding:{provider} |
60s | Embedding provider health |
webhooks:active |
60s | Active webhook list |
stats:platform |
15s | Platform-wide stats |
analytics:{collection} |
5min | Collection query analytics |
Always invalidate on mutations:
async def update_collection(name: str, body: UpdateCollectionRequest):
row = await db.fetchrow(sql, *params)
await invalidate_collection_cache(name) # invalidate after write
return rowFor event streaming, use Redis pub/sub (not in-memory queues):
# Publishing
event_bus.publish(IngestionEvent(
document_id=doc_id,
step="complete",
status="complete",
message="Done",
collection_name=collection_name,
))
# Subscribing (collection-level)
q = event_bus.subscribe(f"collection:{name}")The SDK follows the resource namespace pattern (like Stripe, Anthropic):
// Transport layer (handles HTTP, retries, auth)
export class BigRAGCore implements RequestClient {
readonly apiKey: string;
readonly baseUrl: string;
readonly timeout: number;
readonly maxRetries: number;
}
// Client with resource namespaces
export class BigRAG extends BigRAGCore {
readonly collections: CollectionsResource;
readonly documents: DocumentsResource;
readonly queries: QueryResource;
}Resources receive a RequestClient interface, not the concrete class. Public method names and types should be self-explanatory; keep detailed API guidance in the docs site instead of JSDoc under sdks/typescript/src/:
export class CollectionsResource {
constructor(private readonly _client: RequestClient) {}
list(options?: CollectionListOptions): Promise<CollectionListResponse> {
return this._client._request("GET", "/v1/collections", { params });
}
}Map HTTP status codes to typed error classes:
export class BigRAGError extends Error {}
export class APIError extends BigRAGError {
readonly status: number;
readonly code: string | undefined;
}
export class BadRequestError extends APIError {} // 400
export class AuthenticationError extends APIError {} // 401
export class NotFoundError extends APIError {} // 404
export class RateLimitError extends APIError {} // 429
export class InternalServerError extends APIError {} // 500
// Connection-level errors (no HTTP status)
export class APIConnectionError extends BigRAGError {}
export class APITimeoutError extends BigRAGError {}Retry on connection errors, 5xx responses, and infrastructure 429 responses. Never retry timeouts:
// Retryable
if (response.status >= 500 && attempt < this.maxRetries) continue;
if (response.status === 429 && attempt < this.maxRetries) continue;
// Not retryable - fail immediately
if (lastError.name === "TimeoutError" || lastError.name === "AbortError") {
throw new APITimeoutError(lastError.message);
}Use async generators for SDK pagination helpers:
async *listAllDocuments(collection: string): AsyncGenerator<Document> {
let cursor: string | undefined;
do {
const page = await this.list(collection, { cursor });
for (const document of page.documents) {
yield document;
}
cursor = page.next_cursor ?? undefined;
} while (cursor);
}- Agent Rule Inspiration: sasicodes/start AGENTS.md
- Functional-Light JS: GitHub - getify/Functional-Light-JS
- Clean Code JavaScript: GitHub - ryanmcdermott/clean-code-javascript
- SOLID Principles in TypeScript: LogRocket Blog
- FastAPI: fastapi.tiangolo.com
- Pydantic v2: docs.pydantic.dev
- asyncpg: magicstack.github.io/asyncpg
- structlog: structlog.org
- Ruff: docs.astral.sh/ruff
- React 19: react.dev
- TypeScript: typescriptlang.org
- Vite: vite.dev
- TanStack Router: tanstack.com/router
- TanStack Query: tanstack.com/query
- Tailwind CSS: tailwindcss.com
- Base UI: base-ui.com
- Biome: biomejs.dev
This style guide is a living document. As bigRAG evolves, so should these guidelines. When in doubt, follow existing patterns in the codebase and prioritize clarity and maintainability.