This document outlines best practices and patterns for contributing to the Andamio codebase. Following these guidelines ensures code consistency, maintainability, and optimal performance across the application.
- No client-side business logic: All business logic should be implemented in TRPC routers on the server.
- Client components should be presentational: Client components should focus on UI concerns and delegate data fetching and mutations to custom hooks.
- One query per component: Components should ideally make only one query. If a component needs additional data, create a specific endpoint in the TRPC router.
- Custom hooks for data access: Wrap all TRPC endpoints in custom hooks that follow the
use<EntityName>pattern.
- Use Zod for validation: All input and output schemas should be validated with Zod.
- Leverage TypeScript: Maintain strict typing throughout the codebase.
src/
├── components/ # UI components
├── hooks/ # Custom hooks
│ ├── db/ # Database-related hooks
│ └── cardano-indexer-api/ # External API hooks
├── server/
│ └── api/
│ └── routers/ # TRPC routers
└── utils/ # Utility functions
- Create a new router file in
src/server/api/routers/using the appropriate subfolder - Export the router using the
createTRPCRouterfunction - Define procedures using
publicProcedureorprotectedProcedure
Example:
// src/server/api/routers/database/course/module.ts
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
export const moduleRouter = createTRPCRouter({
getModule: publicProcedure
.input(z.object({ moduleId: z.string() }))
.query(async ({ ctx, input }) => {
// Implementation here
}),
updateModule: protectedProcedure
.input(
z.object({
/* schema */
}),
)
.mutation(async ({ ctx, input }) => {
// Implementation here
}),
});- Always use Zod to validate inputs
- Define detailed schemas with appropriate error messages
- Reuse common schemas when possible
// Good example
.input(
z.object({
moduleId: z.string().min(1, "Module ID is required"),
title: z.string().min(1, "Title is required"),
description: z.string(),
})
)
// Avoid
.input(
z.object({
moduleId: z.string(),
title: z.string(),
description: z.string(),
})
)- Use TRPC's error system to communicate errors to the client
- Include meaningful error messages
- Use appropriate error codes
if (!courseModule) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Module not found",
});
}Create a custom hook for each TRPC query following the use<EntityName> pattern.
// src/hooks/db/course/useCourseModule.ts
export default function useCourseModule(moduleId: string | undefined) {
const {
data: module,
isLoading,
isError,
error,
} = api.module.getModule.useQuery(
{ moduleId: moduleId ?? "" },
{ enabled: !!moduleId },
);
return {
module,
isLoading,
isError,
error,
};
}Create custom hooks for mutations that handle cache invalidation.
// Inside the same hook file
const updateModuleStatus = api.module.updateModuleStatus.useMutation({
onSuccess: async () => {
// Invalidate relevant queries
await ctx.module.getModule.invalidate({ moduleId });
await ctx.module.getCourseModuleOverviews.invalidate();
},
});
// Return in the hook
return {
module,
isLoading,
updateModuleStatus: updateModuleStatus.mutate,
// Other properties
};Components should use the custom hooks instead of directly using the TRPC API.
// Good
function ModuleEditor({ moduleId }: { moduleId: string }) {
const { module, updateModuleStatus } = useCourseModule(moduleId);
// Component implementation
}
// Avoid
function ModuleEditor({ moduleId }: { moduleId: string }) {
const { data: module } = api.module.getModule.useQuery({ moduleId });
const updateModule = api.module.updateModuleStatus.useMutation();
// Component implementation
}- Use
react-hook-formfor form state management - Integrate Zod validation with
zodResolver
const FormSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
});
type FormValues = z.infer<typeof FormSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
title: "",
description: "",
},
});- Use the provided form components from your UI library
- Ensure accessibility is maintained
- Handle loading and error states appropriately
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* More fields */}
<Button type="submit" disabled={isLoading}>
Submit
</Button>
</form>
</Form>- Define prop types explicitly using TypeScript interfaces
- Use sensible defaults and optional props where appropriate
interface TaskDialogProps {
id?: string;
escrowId?: string;
treasuryId?: string;
escrow?: Escrow;
openButtonSize?: "sm" | "lg";
}
export default function DialogTask({
id,
escrowId: defaultEscrowId,
treasuryId: defaultTreasuryId,
escrow,
openButtonSize,
}: TaskDialogProps) {
// Component implementation
}- Minimize state variables: Keep state minimal by deriving values when possible
- Use appropriate initial values: Provide sensible defaults that prevent errors during initial render
- Group related state: Use objects for related state or consider using
useReducerfor complex state - Follow naming conventions: Use
[value, setValue]pattern for clarity
// Good
const [isOpen, setIsOpen] = useState(false);
const isButtonDisabled = isOpen || isLoading;
// Better for related state
const [formState, setFormState] = useState({
isSubmitting: false,
hasErrors: false,
attemptCount: 0,
});
// Update related state immutably
setFormState((prev) => ({
...prev,
attemptCount: prev.attemptCount + 1,
}));
// Avoid
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false); // Derivable from other state
const [submitting, setSubmitting] = useState(false); // Should be grouped with related state- Use for state that triggers re-renders when changed
- Use for values that cannot be derived from props or other state
- Use for UI state like toggles, form inputs, and loading states
- Avoid using for values that can be computed from existing state or props
- Be specific with dependencies: Include all values from the component scope that the effect uses
- Clean up side effects: Return a cleanup function for subscriptions, timers, or event listeners
- Use effect for synchronization: Effects synchronize your component with external systems
- Split unrelated effects: Use multiple
useEffectcalls for unrelated logic - Avoid redundant effects: Don't use effects for state transformations that could be done directly
// Good: Specific purpose with proper cleanup
useEffect(() => {
const subscription = someExternalAPI.subscribe((data) => {
setApiData(data);
});
return () => {
subscription.unsubscribe();
};
}, [someExternalAPI]);
// Good: Loading data conditionally
useEffect(() => {
if (isOpen && !hasLoadedCriteria && escrow?.savedAcceptanceCriteria?.length) {
form.setValue(
"acceptanceCriteria",
escrow.savedAcceptanceCriteria.filter(
(criteria) => criteria.trim() !== "",
),
{ shouldValidate: true },
);
setHasLoadedCriteria(true);
}
}, [escrow, form, isOpen, hasLoadedCriteria]);
// Avoid: Empty dependency array when dependencies exist
useEffect(() => {
setFilteredItems(items.filter((item) => item.isActive));
// Missing dependency: items
}, []);
// Avoid: Side effects that could be simple derivations
useEffect(() => {
setTotalPrice(items.reduce((sum, item) => sum + item.price, 0));
}, [items]); // This should be a derived value instead- Dependency array lies: Missing dependencies that should be included
- Over-synchronization: Running effects for state that could be derived
- Effect chains: One effect setting state that triggers another effect
- Conditional hook calls: Hooks must be called at the top level, never conditionally
- Stale closure problems: Not using the latest state in effect callbacks
// Anti-pattern: Dependency array lie
useEffect(() => {
setFilteredResults(
allItems.filter((item) => item.category === selectedCategory),
);
// Missing dependencies: allItems, selectedCategory
}, []);
// Anti-pattern: Effect chain
useEffect(() => {
setProcessedData(data.map((item) => ({ ...item, processed: true })));
}, [data]);
useEffect(() => {
setFilteredData(processedData.filter((item) => item.isRelevant));
}, [processedData]); // These could be combined or made into a derived value
// Correct approach for the above - derive values
const processedData = useMemo(
() => data.map((item) => ({ ...item, processed: true })),
[data],
);
const filteredData = useMemo(
() => processedData.filter((item) => item.isRelevant),
[processedData],
);- Fetching data that cannot be handled by the query hooks
- Setting up and tearing down subscriptions or connections
- Manually changing the DOM in ways React cannot handle
- Logging or analytics that need to run after renders
- Syncing component state with external storage (localStorage, etc.)
- Transforming data for rendering (use derived state instead)
- Handling user events (use event handlers instead)
- Running logic on every render (do it directly in the component body)
- Running code only once during app initialization (use an app-level service)
- Create reusable UI components for common patterns
- Prefer composition over inheritance
- Use the ShadCN/UI component library when available
- Use Tailwind CSS for styling
- Follow the project's naming conventions
- Use the project's color scheme and design tokens
// Good
<Button
intent="destructive"
size="sm"
onClick={handleRemoveCriterion}
disabled={isLoading}
>
X
</Button>
// Avoid inline styles or separate CSS files- Write tests for new functionality
- Ensure existing tests pass
- Use the provided testing utilities
- Use the provided
.prettierrcconfiguration - Run prettier before submitting code
- Maintain consistent indentation and spacing
- Use appropriate caching strategies
- Memoize expensive computations with
useMemoanduseCallback - Avoid unnecessary re-renders
- Implement pagination for large data sets
- Always validate user input on the server side
- Use protected procedures for authenticated routes
- Never expose sensitive information to the client
- Document complex functions and components
- Use JSDoc comments for public APIs
- Keep documentation updated as code changes
- Create focused PRs that address a single concern
- Include appropriate tests
- Reference related issues
- Ensure CI passes before requesting review