Front-end Engineering Lab
PatternsArchitecture Patterns

Container/Presenter Pattern

Divide components into Smart (handle data) and Dumb (handle only visual) - separation of concerns

The Container/Presenter Pattern divides components into two types: Container (Smart) that handle data and logic, and Presenter (Dumb) that handle only the visual. Although Hooks have changed this, separation of concerns is still vital.

🎯 The Concept

// ❌ BAD: Everything mixed
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser)
      .finally(() => setLoading(false));
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

// ✅ GOOD: Container/Presenter
// Container (Smart) - handles data
function UserProfileContainer({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser)
      .finally(() => setLoading(false));
  }, [userId]);
  
  return <UserProfilePresenter user={user} loading={loading} />;
}

// Presenter (Dumb) - handles only visual
function UserProfilePresenter({ 
  user, 
  loading 
}: { 
  user: User | null; 
  loading: boolean;
}) {
  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;
  
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

📚 Common Front-End Examples

1. User List

// Container - fetches data
function UserListContainer() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);
  
  return (
    <UserListPresenter
      users={users}
      loading={loading}
      error={error}
    />
  );
}

// Presenter - only renders
function UserListPresenter({
  users,
  loading,
  error
}: {
  users: User[];
  loading: boolean;
  error: Error | null;
}) {
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>
          <UserCard user={user} />
        </li>
      ))}
    </ul>
  );
}

2. Form with Validation

import { useState } from 'react';

// Container - validation and submit logic
async function login(credentials: { email: string; password: string }): Promise<void> {
  const res = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credentials)
  });
  if (!res.ok) throw new Error('Login failed');
}

function LoginFormContainer() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [loading, setLoading] = useState(false);
  
  const validate = () => {
    const newErrors: Record<string, string> = {};
    
    if (!email) newErrors.email = 'Email is required';
    if (!email.includes('@')) newErrors.email = 'Invalid email';
    if (!password) newErrors.password = 'Password is required';
    if (password.length < 6) newErrors.password = 'Password too short';
    
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!validate()) return;
    
    setLoading(true);
    try {
      await login({ email, password });
    } catch (error) {
      setErrors({ submit: 'Login error' });
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <LoginFormPresenter
      email={email}
      password={password}
      errors={errors}
      loading={loading}
      onEmailChange={setEmail}
      onPasswordChange={setPassword}
      onSubmit={handleSubmit}
    />
  );
}

// Presenter - only UI
function LoginFormPresenter({
  email,
  password,
  errors,
  loading,
  onEmailChange,
  onPasswordChange,
  onSubmit
}: {
  email: string;
  password: string;
  errors: Record<string, string>;
  loading: boolean;
  onEmailChange: (value: string) => void;
  onPasswordChange: (value: string) => void;
  onSubmit: (e: React.FormEvent) => void;
}) {
  return (
    <form onSubmit={onSubmit}>
      <div>
        <input
          type="email"
          value={email}
          onChange={(e) => onEmailChange(e.target.value)}
        />
        {errors.email && <span>{errors.email}</span>}
      </div>
      
      <div>
        <input
          type="password"
          value={password}
          onChange={(e) => onPasswordChange(e.target.value)}
        />
        {errors.password && <span>{errors.password}</span>}
      </div>
      
      {errors.submit && <div>{errors.submit}</div>}
      
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

3. Dashboard with Multiple Data Sources

import { useState, useEffect } from 'react';

interface Stats {
  totalUsers: number;
  totalOrders: number;
}

interface Activity {
  id: string;
  action: string;
  timestamp: string;
}

// Container - fetches multiple data sources
function DashboardContainer() {
  const [stats, setStats] = useState<Stats | null>(null);
  const [recentActivity, setRecentActivity] = useState<Activity[]>([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    Promise.all([
      fetch('/api/stats').then(res => res.json()),
      fetch('/api/activity').then(res => res.json())
    ]).then(([statsData, activityData]) => {
      setStats(statsData);
      setRecentActivity(activityData);
    }).finally(() => setLoading(false));
  }, []);
  
  return (
    <DashboardPresenter
      stats={stats}
      recentActivity={recentActivity}
      loading={loading}
    />
  );
}

// Presenter - only layout
function DashboardPresenter({
  stats,
  recentActivity,
  loading
}: {
  stats: Stats | null;
  recentActivity: Activity[];
  loading: boolean;
}) {
  if (loading) return <Spinner />;
  
  return (
    <div className="dashboard">
      <StatsCards stats={stats} />
      <RecentActivityList activities={recentActivity} />
    </div>
  );
}

4. With Custom Hooks

import { useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
  avatar: string;
  bio: string;
}

// Custom hook - reusable logic
function useUserData(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);
  
  return { user, loading, error };
}

// Container - uses hook
function UserProfileContainer({ userId }: { userId: string }) {
  const { user, loading, error } = useUserData(userId);
  
  return (
    <UserProfilePresenter
      user={user}
      loading={loading}
      error={error}
    />
  );
}

// Presenter - pure, testable
function UserProfilePresenter({
  user,
  loading,
  error
}: {
  user: User | null;
  loading: boolean;
  error: Error | null;
}) {
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return <div>User not found</div>;
  
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

5. Data Table with Filters

import { useState, useEffect } from 'react';

interface Item {
  id: string;
  name: string;
  date: string;
}

// Container - filter and pagination logic
function DataTableContainer() {
  const [data, setData] = useState<Item[]>([]);
  const [filteredData, setFilteredData] = useState<Item[]>([]);
  const [searchTerm, setSearchTerm] = useState('');
  const [sortBy, setSortBy] = useState<keyof Item>('name');
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
  const [currentPage, setCurrentPage] = useState(1);
  const pageSize = 10;
  
  useEffect(() => {
    fetch('/api/items')
      .then(res => res.json())
      .then(setData);
  }, []);
  
  useEffect(() => {
    let filtered = [...data];
    
    // Filter
    if (searchTerm) {
      filtered = filtered.filter(item =>
        item.name.toLowerCase().includes(searchTerm.toLowerCase())
      );
    }
    
    // Sort
    filtered.sort((a, b) => {
      const aVal = a[sortBy];
      const bVal = b[sortBy];
      const comparison = aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
      return sortOrder === 'asc' ? comparison : -comparison;
    });
    
    // Paginate
    const start = (currentPage - 1) * pageSize;
    const paginated = filtered.slice(start, start + pageSize);
    
    setFilteredData(paginated);
  }, [data, searchTerm, sortBy, sortOrder, currentPage]);
  
  return (
    <DataTablePresenter
      data={filteredData}
      searchTerm={searchTerm}
      sortBy={sortBy}
      sortOrder={sortOrder}
      currentPage={currentPage}
      totalPages={Math.ceil(data.length / pageSize)}
      onSearchChange={setSearchTerm}
      onSortChange={setSortBy}
      onSortOrderChange={setSortOrder}
      onPageChange={setCurrentPage}
    />
  );
}

// Presenter - only rendering
function DataTablePresenter({
  data,
  searchTerm,
  sortBy,
  sortOrder,
  currentPage,
  totalPages,
  onSearchChange,
  onSortChange,
  onSortOrderChange,
  onPageChange
}: {
  data: Item[];
  searchTerm: string;
  sortBy: keyof Item;
  sortOrder: 'asc' | 'desc';
  currentPage: number;
  totalPages: number;
  onSearchChange: (value: string) => void;
  onSortChange: (field: keyof Item) => void;
  onSortOrderChange: (order: 'asc' | 'desc') => void;
  onPageChange: (page: number) => void;
}) {
  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => onSearchChange(e.target.value)}
        placeholder="Search..."
      />
      
      <table>
        <thead>
          <tr>
            <th onClick={() => onSortChange('name')}>
              Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
            </th>
            <th onClick={() => onSortChange('date')}>
              Date {sortBy === 'date' && (sortOrder === 'asc' ? '↑' : '↓')}
            </th>
          </tr>
        </thead>
        <tbody>
          {data.map(item => (
            <tr key={item.id}>
              <td>{item.name}</td>
              <td>{item.date}</td>
            </tr>
          ))}
        </tbody>
      </table>
      
      <div>
        <button
          onClick={() => onPageChange(currentPage - 1)}
          disabled={currentPage === 1}
        >
          Previous
        </button>
        <span>Page {currentPage} of {totalPages}</span>
        <button
          onClick={() => onPageChange(currentPage + 1)}
          disabled={currentPage === totalPages}
        >
          Next
        </button>
      </div>
    </div>
  );
}

// Usage
function App() {
  return <DataTableContainer />;
}

🎯 When to Use

This pattern is commonly used and recommended for:

  1. Components with complex logic - Separate data fetching from rendering
  2. UI reuse - Same Presenter can be used with different Containers
  3. Testability - Test logic (Container) and UI (Presenter) separately
  4. Team collaboration - Designers work on Presenters, devs on Containers
  5. Refactoring - Change logic without affecting UI and vice versa

🔄 Evolution with Hooks

With Hooks, the pattern evolved:

// Before: Separate Container/Presenter
function Container() { /* logic */ }
function Presenter() { /* UI */ }

// Now: Hook + Component
function useData() { /* logic */ }
function Component() {
  const data = useData();
  return /* UI */;
}

But separation is still useful for:

  • Very complex components
  • UI reuse
  • Testability

📚 Key Takeaways

  • Separation of concerns - Logic vs Visual
  • Testability - Test each part in isolation
  • Reusability - Same Presenter with different Containers
  • Maintainability - Isolated changes don't affect other parts
  • Collaboration - Designers and devs work on different parts

On this page