-
Notifications
You must be signed in to change notification settings - Fork 20
Development Coding Standards Frontend
Beautiful, accessible code that works on every device — from the latest iPhone to a 5-year-old Android phone on 2G.
Our frontend serves students globally, which means our code must:
- Load fast on 2G networks (50 kbps)
- Work everywhere from old phones to new laptops
- Be accessible for all abilities and languages
- Stay maintainable for contributors worldwide
Our Goal: Write React code so clean that a student contributor can understand it quickly and users on slow connections never wait.
- Project Structure
- React Components
- TypeScript Guidelines
- API Services & Data Fetching
- State Management
- Styling with Tailwind
- Accessibility
- Internationalization
- Testing
- Code Quality
- Related Pages
Frontend/EduLiteFrontend/src/
├── components/ # Reusable UI components
│ ├── common/ # Shared components (Button, Input, modals, selects)
│ ├── slideshow/ # Slideshow feature components
│ │ └── editor/ # Slideshow editor components
│ └── *.tsx/jsx # Top-level shared components (Navbar, Footer, etc.)
├── pages/ # Page components (one per route)
├── hooks/ # Custom React hooks
├── services/ # API clients (coursesApi, slideshowApi, tokenService, etc.)
├── types/ # TypeScript type definitions (*.types.ts)
├── contexts/ # React Context providers (AuthContext)
├── utils/ # Helper functions (errorUtils, etc.)
├── i18n/ # Internationalization
│ └── locales/ # Language JSON files (en.json, ar.json)
├── test/ # Test setup and utilities
├── assets/ # Images, fonts, static files
├── App.jsx # Main app component
└── main.jsx # Entry point
# Components: PascalCase
Button.tsx # TypeScript component (preferred for new files)
Navbar.tsx
BackToTopButton.jsx # JavaScript component (still supported)
# Hooks: camelCase with 'use' prefix
useAuth.ts
useCourses.ts
useSlideLoader.ts
# Services: camelCase
coursesApi.ts
tokenService.ts
slideshowApi.ts
# Types: camelCase with .types.ts extension
courses.types.ts
slideshow.types.ts
auth.types.ts
# Utils: camelCase
errorUtils.ts
# Tests: same name as source + .test.ts/.test.tsx in __tests__/ folder
services/__tests__/coursesApi.test.ts
hooks/__tests__/useCourses.test.ts
components/common/__tests__/Button.test.tsx
// BAD: Everything in one component
function UserDashboard() {
// 500 lines of mixed concerns...
}
// GOOD: Separated concerns
function UserDashboard() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<UserInfo />
<NotificationPanel />
<CourseList />
<FriendActivity />
</div>
);
}// BAD: Logic mixed with UI
function AssignmentList() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/assignments')
.then(res => res.json())
.then(data => { setAssignments(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
// ... 200 lines of UI ...
}
// GOOD: Logic extracted to custom hook
function useAssignments() {
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchAssignments()
.then(setAssignments)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { assignments, loading, error };
}
function AssignmentList() {
const { assignments, loading, error } = useAssignments();
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <AssignmentGrid assignments={assignments} />;
}import React, { ChangeEvent, InputHTMLAttributes } from "react";
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
name: string;
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
label?: string;
error?: string;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ name, value, onChange, label, error, ...props }, ref) => {
return (
<div>
{label && <label htmlFor={name}>{label}</label>}
<input ref={ref} id={name} name={name} value={value} onChange={onChange} {...props} />
{error && <span className="error">{error}</span>}
</div>
);
}
);
Input.displayName = "Input";
export default Input;We're gradually migrating from JSX to TypeScript. New files should be .tsx/.ts. Both .jsx and .tsx are supported.
-
No
any— use specific types orunknown -
Let TypeScript infer when the type is obvious (
const name = 'Ahmed'notconst name: string = 'Ahmed') - Define interfaces for component props, API responses, and state objects
-
Use utility types —
Partial<T>,Pick<T, K>,Omit<T, K>
interface ButtonProps {
children: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
type?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
className?: string;
}
const Button: React.FC<ButtonProps> = ({ children, onClick, type = 'primary', ...props }) => {
return <button onClick={onClick} {...props}>{children}</button>;
};Define types in src/types/ that mirror the backend serializers:
// types/courses.types.ts
export type CourseVisibility = "public" | "restricted" | "private";
export type CourseRole = "teacher" | "student" | "assistant";
export interface CourseListItem {
id: number;
title: string;
outline: string | null;
visibility: CourseVisibility;
member_count: number;
// ...
}
export interface CoursePaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
total_pages: number;
current_page: number;
page_size: number;
results: T[];
}function useCourses(params?: CourseListParams): {
courses: CoursePaginatedResponse<CourseListItem> | null;
loading: boolean;
error: string | null;
refetch: () => void;
}For a full 30-minute TypeScript tutorial with migration guides and troubleshooting, see the TypeScript Guide.
We follow a strict layered pattern:
types/*.types.ts → TypeScript types mirroring backend serializers
services/*Api.ts → API functions (axios calls + error handling)
hooks/use*.ts → React hooks wrapping API functions (loading/error/refetch)
pages/*.tsx → Page components consuming hooks
All API services follow the same pattern established in slideshowApi.ts:
// services/coursesApi.ts
import axios from "axios";
import { getSafeErrorMessage } from "../utils/errorUtils";
const API_BASE_URL = "http://localhost:8000/api";
// Auth headers handled automatically by tokenService interceptors
export const listCourses = async (
params?: CourseListParams
): Promise<CoursePaginatedResponse<CourseListItem>> => {
try {
const response = await axios.get(`${API_BASE_URL}/courses/`, {
params,
timeout: 10000,
});
return response.data;
} catch (error) {
throw new Error(getSafeErrorMessage(error, "Failed to load courses"));
}
};Key rules:
- Use the global
axiosinstance (tokenService interceptors handle JWT auth automatically) - Use
getSafeErrorMessage()fromerrorUtils.tsfor all error handling — never expose raw backend errors to users - Timeout:
10000msfor reads,15000msfor writes - Return types must match the backend serializer response shape exactly
// hooks/useCourses.ts
export function useCourses(params?: CourseListParams) {
const [courses, setCourses] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fetchKey, setFetchKey] = useState(0);
const refetch = useCallback(() => setFetchKey(prev => prev + 1), []);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const data = await listCourses(params);
if (!cancelled) setCourses(data);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load");
} finally {
if (!cancelled) setLoading(false);
}
};
fetchData();
return () => { cancelled = true; };
}, [/* deps */, fetchKey]);
return { courses, loading, error, refetch };
}Key rules:
- Always use a
cancelledflag to prevent state updates on unmounted components - Always return
{ data, loading, error, refetch } -
refetchuses afetchKeycounter pattern
Use useState for UI-local state:
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);Use React Context for app-wide state. See AuthContext.tsx for the canonical pattern:
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// ... auth logic ...
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}-
Mobile-first: start with base styles, add
sm:,md:,lg:breakpoints -
Always include dark mode: every color class needs a
dark:counterpart -
Use logical properties for RTL:
ms-auto/me-autoinstead ofml-auto/mr-auto
<div className="
grid gap-4
grid-cols-1
sm:grid-cols-2
lg:grid-cols-3
xl:grid-cols-4
">
{courses.map(course => <CourseCard key={course.id} course={course} />)}
</div><span className="
px-2 py-1 rounded-full text-xs font-medium
bg-green-100 text-green-800
dark:bg-green-900 dark:text-green-200
">
Online
</span>-
ARIA attributes:
role,aria-label,aria-expanded,aria-modalon interactive elements -
Keyboard navigation: support
Escape,ArrowDown,ArrowUp,Enteron dropdowns and modals - Focus management: trap focus in modals, auto-focus cancel button in confirmation dialogs
-
Semantic HTML: use
<button>for actions,<a>for navigation,<ul>/<li>for lists
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm Action</h2>
<button onClick={onClose} aria-label="Close modal">
<span aria-hidden="true">×</span>
</button>
</div>All user-facing text must use translation keys via react-i18next:
import { useTranslation } from 'react-i18next';
function WelcomeMessage({ userName }) {
const { t } = useTranslation();
return (
<h1>{t('welcome.title', { name: userName })}</h1>
);
}Translation files live in src/i18n/locales/:
// en.json
{
"welcome": {
"title": "Welcome back, {{name}}!"
},
"common": {
"loading": "Loading...",
"error": "Something went wrong",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
}
}Never hardcode user-facing strings. Always add both en.json and ar.json entries.
We use Vitest with React Testing Library for frontend testing.
npm run test # Run all tests once
npm run test:watch # Watch mode (re-runs on file changes)
npm run test:ui # Interactive browser UI
npm run test:coverage # With coverage reportTests live in __tests__/ folders next to the code they test:
src/
├── components/common/
│ ├── Button.tsx
│ └── __tests__/
│ ├── Button.test.tsx
│ └── Button.edge.test.tsx
├── hooks/
│ ├── useCourses.ts
│ └── __tests__/
│ └── useCourses.test.ts
└── services/
├── coursesApi.ts
└── __tests__/
└── coursesApi.test.ts
| Layer | What to Test |
|---|---|
| Components | Rendering, user interactions, conditional states, accessibility |
| API services | Success responses, error handling, query parameters, HTTP error codes |
| Custom hooks | Loading/error states, data fetching, refetch, cleanup on unmount |
| Tool | Purpose |
|---|---|
vitest |
Test runner (Jest-compatible API) |
@testing-library/react |
Component rendering and DOM queries |
@testing-library/user-event |
Realistic user interaction simulation |
axios-mock-adapter |
HTTP request mocking for API service tests |
renderWithProviders |
Custom render with Router/Auth/i18n contexts (src/test/utils.tsx) |
npm run test # All tests pass
npm run type-check # TypeScript compiles
npm run lint # No linting errors
npx vite build # Build succeedsFor the full testing guide with detailed patterns and examples, see Code Testing for Frontend.
We use the flat config format (eslint.config.js) with react-hooks and react-refresh plugins. Run npm run lint before every PR.
// 1. React imports
import { useState, useEffect, useCallback } from 'react';
// 2. Third-party libraries
import { useTranslation } from 'react-i18next';
import axios from 'axios';
// 3. Internal imports — absolute paths
import { useAuth } from '@/contexts/AuthContext';
// 4. Internal imports — relative paths
import { listCourses } from '../services/coursesApi';
import type { CourseListItem } from '../types/courses.types';Comments explain why, not what:
// BAD
counter++; // Increment counter
// GOOD
// Students on 2G often need 3-5 attempts due to packet loss
counter++;
// GOOD
// Students in conflict zones might lose internet for days.
// Keep their work in localStorage for 30 days instead of the default 7.
const OFFLINE_CACHE_DURATION = 30 * 24 * 60 * 60 * 1000;Wrap page-level components in error boundaries for graceful failure:
// components/ErrorBoundary.jsx — already exists in the codebase
<ErrorBoundary>
<App />
</ErrorBoundary>- Performance — fast loading on slow networks
- Accessibility — usable by everyone
- Maintainability — easy for our global volunteer team
- Reliability — works in all conditions
- Type safety — catch errors at build time, not in production
- Components — small, focused, separated concerns
- TypeScript — preferred for all new files
- API layer — types → services → hooks → pages
- Test everything — students depend on us
- Accessibility always — no student left behind
- Code Testing for Frontend — Full testing guide with Vitest patterns, examples, and what to test
- TypeScript Guide — 30-minute TypeScript tutorial for EduLite contributors
- Foundational UI - Button, Input — Component documentation
These standards evolve with our community. Suggest improvements via pull request!
Beautiful code isn't just about aesthetics — it's about creating interfaces that work for a student in rural Sudan on a 2G connection just as well as they work in a Toronto university. When we code with empathy, we code for everyone.