Modern Frontend Architecture: Patterns and Best Practices
Frontend12 min read

Modern Frontend Architecture: Patterns and Best Practices

Build scalable frontend applications with proven architectural patterns. Component design, state management, folder structure, and separation of concerns.

Taha Kocal

Taha Kocal

Full Stack Developer

Nov 10, 2024
#Architecture#Frontend#React#Design Patterns#Scalability

Frontend architecture is the set of structural decisions - folder organization, component patterns, state management, and separation of concerns - that determines how a web application scales as its codebase and team grow. A well-architected frontend organizes code by feature rather than by file type, keeps business logic out of presentational components, and matches each piece of state to the right tool: local state for UI details, React Context for low-frequency global values like themes, and dedicated stores such as Zustand for complex shared state. Two patterns carry most of the weight in React applications: compound components, which expose flexible, composable APIs for complex UI, and the container/presentation split, which separates data fetching from rendering so both can be tested in isolation. This guide walks through these proven patterns - feature-based folder structure, component design, state management, custom hooks, error boundaries, and testing - with practical TypeScript examples you can apply immediately.

Why Use a Feature-Based Folder Structure?

Organizing code by feature rather than type makes it easier to find and maintain related files.

bash
src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── RegisterForm.tsx
│   │   ├── hooks/
│   │   │   └── useAuth.ts
│   │   ├── services/
│   │   │   └── authService.ts
│   │   ├── types/
│   │   │   └── index.ts
│   │   └── index.ts
│   ├── products/
│   └── cart/
├── shared/
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   └── types/
├── pages/
└── App.tsx

Benefits of feature-based structure:

  • Colocation: Related files stay together
  • Scalability: Easy to add new features
  • Discoverability: Clear where to find things
  • Isolation: Features can be developed independently

Which Component Patterns Scale Best?

Compound Components

Compound components provide a flexible, composable API for complex UI elements.

tsx
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function Tab({ value, children }: { value: string; children: React.ReactNode }) {
  const { activeTab, setActiveTab } = useContext(TabsContext)!;

  return (
    <button
      className={activeTab === value ? 'active' : ''}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
  const { activeTab } = useContext(TabsContext)!;
  if (activeTab !== value) return null;
  return <div className="tab-panel">{children}</div>;
}

// Attach sub-components
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

// Usage
<Tabs defaultTab="profile">
  <Tabs.Tab value="profile">Profile</Tabs.Tab>
  <Tabs.Tab value="settings">Settings</Tabs.Tab>
  <Tabs.Panel value="profile">Profile content</Tabs.Panel>
  <Tabs.Panel value="settings">Settings content</Tabs.Panel>
</Tabs>

Container/Presentation Pattern

Separate logic from UI by splitting components into containers (logic) and presentational (UI) components.

tsx
// Container (Logic)
function UserListContainer() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const handleDelete = useMutation({
    mutationFn: deleteUser,
    onSuccess: () => queryClient.invalidateQueries(['users']),
  });

  if (isLoading) return <UserListSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <UserList
      users={users}
      onDelete={handleDelete.mutate}
      isDeleting={handleDelete.isPending}
    />
  );
}

// Presentation (UI)
interface UserListProps {
  users: User[];
  onDelete: (id: string) => void;
  isDeleting: boolean;
}

function UserList({ users, onDelete, isDeleting }: UserListProps) {
  return (
    <ul className="space-y-4">
      {users.map(user => (
        <li key={user.id} className="flex justify-between items-center">
          <span>{user.name}</span>
          <button onClick={() => onDelete(user.id)} disabled={isDeleting}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

How Should You Manage State?

Context for Global State

tsx
interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('dark');

  const toggleTheme = useCallback(() => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
}

Zustand for Complex State

tsx
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  total: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) =>
        set((state) => ({
          items: [...state.items, item],
        })),
      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
        })),
      clearCart: () => set({ items: [] }),
      total: () => get().items.reduce((sum, item) => sum + item.price, 0),
    }),
    { name: 'cart-storage' }
  )
);

// Usage
function Cart() {
  const { items, removeItem, total } = useCartStore();
  // ...
}

Custom Hooks

Data Fetching Hook

tsx
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error('Failed to fetch');
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

Error Boundaries

tsx
class ErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error:', error, errorInfo);
    // Send to error tracking service
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// Usage
<ErrorBoundary fallback={<ErrorFallback />}>
  <App />
</ErrorBoundary>

Testing Strategy

tsx
import { render, screen, fireEvent } from '@testing-library/react';

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);
    fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

A solid architecture enables maintainability, scalability, testability, reusability, and effective team collaboration. Choose patterns that fit your project size and team.

Start simple and refactor as complexity grows. The best architecture is one that serves your current needs while being flexible enough to evolve with your application.

Share this article

Related Articles