Good frontend architecture enables teams to move fast without breaking things. Here's how to build scalable applications.
Core Principles
Separation of Concerns
Keep different responsibilities isolated.
src/
├── components/ # UI components
├── pages/ # Page components
├── hooks/ # Custom hooks
├── services/ # API calls
├── store/ # State management
├── utils/ # Helper functions
└── types/ # TypeScript definitionsSingle Responsibility
Each module should have one reason to change.
// Bad: Component does too much
function UserProfile() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchUser();
fetchPosts();
}, []);
async function fetchUser() {
const data = await api.get('/user');
setUser(data);
}
async function fetchPosts() {
const data = await api.get('/posts');
setPosts(data);
}
return <div>{/* ... */}</div>;
}
// Good: Separated concerns
function UserProfile() {
const { user, posts, loading } = useUserProfile();
if (loading) return <Spinner />;
return <UserProfileView user={user} posts={posts} />;
}Project Structure
Feature-Based Organization
Group by feature, not by type.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── types/
│ │ └── index.ts
│ └── dashboard/
│ ├── components/
│ ├── hooks/
│ └── index.ts
└── shared/
├── components/
├── hooks/
└── utils/Domain-Driven Design
Align structure with business domains.
// Domain: User Management
// Feature: Authentication
// Component: LoginForm
// Location: features/auth/components/LoginForm.tsxState Management Strategy
Types of State
- 1Server State: Data from APIs
- 2Client State: Form inputs, UI state
- 3URL State: Query params, hash
- 4Temp State: Modals, dropdowns
Tool Selection
// Server State - React Query
const { data, isLoading } = useQuery('users', fetchUsers);
// Client State - Zustand
const useStore = create((set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}));
// URL State - React Router
const [searchParams] = useSearchParams();
const page = searchParams.get('page');
// Temp State - useState
const [isModalOpen, setIsModalOpen] = useState(false);Component Architecture
Atomic Design
Break down UI into hierarchy.
atoms/
├── Button.tsx
├── Input.tsx
└── Badge.tsx
molecules/
├── SearchBar.tsx # Input + Button
├── FormField.tsx # Label + Input + Error
└── UserAvatar.tsx # Image + Badge
organisms/
├── Header.tsx # Logo + Nav + Search
├── Card.tsx # Image + Title + Actions
└── DataTable.tsx # Pagination + Filters + TableComponent Composition
Build complex UIs from simple parts.
// Complex component from simple parts
<Card>
<CardHeader>
<CardTitle>User Profile</CardTitle>
<CardActions>
<Button>Edit</Button>
<Button variant="danger">Delete</Button>
</CardActions>
</CardHeader>
<CardBody>
<UserAvatar user={user} />
<UserInfo user={user} />
</CardBody>
</Card>Data Layer
Repository Pattern
Abstract data access.
// Repository
class UserRepository {
async findById(id) {
const response = await api.get(`/users/${id}`);
return UserDTO.fromJSON(response.data);
}
async findAll(filters) {
const response = await api.get('/users', { params: filters });
return response.data.map(data => UserDTO.fromJSON(data));
}
}
// Usage
const user = await userRepository.findById('123');Service Layer
Business logic separation.
class AuthService {
constructor(private userRepository: UserRepository) {}
async login(email, password) {
const user = await this.userRepository.findByEmail(email);
if (!user || !user.verifyPassword(password)) {
throw new AuthError('Invalid credentials');
}
return this.generateToken(user);
}
}Error Handling
Global Error Boundary
Catch React errors.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
logError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}API Error Handling
Centralized error handling.
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
redirectToLogin();
} else if (error.response?.status === 500) {
showServerError();
}
return Promise.reject(error);
}
);Code Quality
Linting and Formatting
Enforce consistency.
{
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"no-console": "warn",
"prefer-const": "error",
"@typescript-eslint/explicit-function-return-type": "warn"
}
}Code Reviews
Review checklist:
- Does it follow patterns?
- Is it tested?
- Is it performant?
- Is it accessible?
- Is it secure?
Testing Strategy
// Unit tests
describe('formatDate', () => {
it('formats date correctly', () => {
expect(formatDate('2024-01-01')).toBe('Jan 1, 2024');
});
});
// Integration tests
describe('LoginForm', () => {
it('submits form with credentials', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByLabelText('Email'), 'test@example.com');
await userEvent.click(screen.getByText('Submit'));
expect(mockLogin).toHaveBeenCalledWith('test@example.com');
});
});
// E2E tests
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});Performance
Code Splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>Memoization
// Memoize expensive calculations
const expensiveValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
// Memoize callbacks
const handleClick = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// Memoize components
const ExpensiveComponent = memo(({ data }) => {
return <ComplexVisualization data={data} />;
});Documentation
Component Documentation
/**
* Button component with variants and sizes
*
* @param variant - 'primary' | 'secondary' | 'ghost'
* @param size - 'sm' | 'md' | 'lg'
* @param isLoading - Shows loading spinner
*
* @example
* <Button variant="primary" size="lg" onClick={handleClick}>
* Submit
* </Button>
*/
export function Button({ variant, size, isLoading, children, ...props }) {
// ...
}Architecture Decision Records
Document major decisions.
# ADR-001: Adopt React Query
## Status
Accepted
## Context
Need better server state management
## Decision
Use React Query for all server state
## Consequences
- Less boilerplate code
- Better caching
- Team needs to learn new libraryConclusion
Good frontend architecture is about patterns that scale. Separate concerns, organize by feature, choose appropriate state management, build composable components, and maintain code quality. The goal is to enable fast development without sacrificing maintainability.