DEV Community

Md Shahjalal
Md Shahjalal

Posted on

Mastering React with the Container-Presenter Pattern: A Deep Dive

Mastering React with the Container-Presenter Pattern: A Deep Dive

In the evolving landscape of React development, writing maintainable, scalable, and testable code isn't optional—it's essential. The Container-Presenter Pattern (also called Smart-Dumb Components, though the former terminology is preferred for its neutral connotation) stands as one of the most fundamental architectural patterns for achieving clean component design.

This comprehensive guide will walk you through everything you need to know: understanding the pattern's philosophy, recognizing when your code needs refactoring, implementing it step-by-step with detailed examples, and making informed decisions about when to use (and not use) this pattern.


🔍 Understanding the Fundamental Problem

The Monolithic Component Anti-Pattern

Let's start with a real-world scenario. You're tasked with building a user profile page for a social media application. The requirements seem straightforward:

  • Fetch and display user information (name, email, bio, avatar)
  • Show a list of the user's posts
  • Allow inline editing of profile details
  • Handle loading states during data fetches
  • Display error messages and provide retry functionality
  • Track whether the user is in "edit mode"

At first, you might be tempted to build this all in one component. Here's what that typically looks like:

// UserProfile.jsx (THE PROBLEMATIC MONOLITH)
import { useState, useEffect } from 'react';
import axios from 'axios';
import './UserProfile.css';

const UserProfile = ({ userId }) => {
  // State management (6 different pieces of state!)
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [isEditing, setIsEditing] = useState(false);
  const [formData, setFormData] = useState({ name: '', email: '', bio: '' });

  // Data fetching logic
  const fetchUserData = async () => {
    try {
      setLoading(true);
      setError(null);
      const response = await axios.get(`/api/users/${userId}`);
      setUser(response.data);
      setFormData({
        name: response.data.name,
        email: response.data.email,
        bio: response.data.bio
      });
    } catch (err) {
      setError(err.message || 'Failed to fetch user data');
    } finally {
      setLoading(false);
    }
  };

  const fetchUserPosts = async () => {
    try {
      const response = await axios.get(`/api/posts?userId=${userId}`);
      setPosts(response.data);
    } catch (err) {
      console.error('Failed to fetch posts:', err);
    }
  };

  // Business logic
  const handleUpdateUser = async () => {
    try {
      setLoading(true);
      const response = await axios.put(`/api/users/${userId}`, formData);
      setUser(response.data);
      setIsEditing(false);
    } catch (err) {
      setError('Failed to update profile');
    } finally {
      setLoading(false);
    }
  };

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleRetry = () => {
    setError(null);
    fetchUserData();
    fetchUserPosts();
  };

  const handleStartEdit = () => {
    setIsEditing(true);
    setFormData({
      name: user.name,
      email: user.email,
      bio: user.bio
    });
  };

  const handleCancelEdit = () => {
    setIsEditing(false);
    setFormData({
      name: user.name,
      email: user.email,
      bio: user.bio
    });
  };

  // Effects
  useEffect(() => {
    fetchUserData();
    fetchUserPosts();
  }, [userId]);

  // Rendering logic (80+ lines of JSX!)
  if (loading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>Loading user profile...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="error-container">
        <h2>Oops! Something went wrong</h2>
        <p>{error}</p>
        <button onClick={handleRetry}>Try Again</button>
      </div>
    );
  }

  return (
    <div className="user-profile">
      <div className="profile-header">
        <img src={user.avatar} alt={user.name} className="avatar" />

        {isEditing ? (
          <div className="edit-form">
            <input
              type="text"
              name="name"
              value={formData.name}
              onChange={handleInputChange}
              placeholder="Name"
            />
            <input
              type="email"
              name="email"
              value={formData.email}
              onChange={handleInputChange}
              placeholder="Email"
            />
            <textarea
              name="bio"
              value={formData.bio}
              onChange={handleInputChange}
              placeholder="Bio"
              rows="4"
            />
            <div className="button-group">
              <button onClick={handleUpdateUser} className="save-btn">
                Save Changes
              </button>
              <button onClick={handleCancelEdit} className="cancel-btn">
                Cancel
              </button>
            </div>
          </div>
        ) : (
          <div className="profile-info">
            <h1>{user.name}</h1>
            <p className="email">{user.email}</p>
            <p className="bio">{user.bio}</p>
            <button onClick={handleStartEdit} className="edit-btn">
              Edit Profile
            </button>
          </div>
        )}
      </div>

      <div className="posts-section">
        <h2>Recent Posts</h2>
        {posts.length === 0 ? (
          <p className="no-posts">No posts yet</p>
        ) : (
          <ul className="posts-list">
            {posts.map(post => (
              <li key={post.id} className="post-item">
                <h3>{post.title}</h3>
                <p>{post.content}</p>
                <span className="post-date">
                  {new Date(post.createdAt).toLocaleDateString()}
                </span>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
};

export default UserProfile;
Enter fullscreen mode Exit fullscreen mode

Why This Approach Fails at Scale

This 150+ line monolithic component demonstrates several critical code smells:

1. Violation of Single Responsibility Principle

The component is responsible for:

  • API communication (data fetching and mutations)
  • State management (6 different state variables)
  • Business logic (form validation, edit mode toggling)
  • UI rendering (loading states, error states, profile display, posts list)
  • Event handling (user interactions)

A single change request (like adding a profile picture upload) requires touching a file that already manages too many concerns—increasing bug risk exponentially.

2. Testing Nightmare

To test this component, you need to:

  • Mock the entire axios library
  • Simulate complex user interactions (clicking edit, typing, saving)
  • Manage asynchronous state updates
  • Test loading and error states
  • Verify UI rendering for multiple conditional branches

A single test file ends up being 200+ lines with intricate setup logic.

3. Zero Reusability

Want to use that loading spinner on another page? You'll copy-paste the JSX. Need the error message component elsewhere? Copy-paste again. This leads to code duplication and inconsistent UIs.

4. Poor Readability and Maintainability

New developers joining the project need to understand the entire component to make even small changes. The mental model required is too complex—mixing data flow with presentation logic.

5. Difficult to Optimize

React's rendering optimization tools (like React.memo, useMemo, useCallback) become harder to apply effectively when logic and UI are entangled.


🎯 The Container-Presenter Solution

The Container-Presenter pattern addresses these issues through separation of concerns—splitting components into two distinct categories based on their responsibilities.

📦 Container Components (The "Brain")

Primary Responsibilities:

  • Manage application state (useState, useReducer)
  • Handle side effects (useEffect, data fetching)
  • Implement business logic (validation, calculations, transformations)
  • Connect to external data sources (APIs, Context, Redux)
  • Process user actions and update state accordingly

Key Characteristics:

  • Contains minimal to no JSX—typically just renders a single Presenter component
  • Passes data and callback functions as props
  • Often named with Container suffix or placed in a containers/ directory
  • Can use hooks freely for state management and side effects
  • May connect to global state management systems

What Containers DON'T Do:

  • Define styling or CSS classes
  • Contain complex JSX structures
  • Handle layout or visual presentation
  • Worry about accessibility attributes (like ARIA labels)

🖼️ Presenter Components (The "Face")

Primary Responsibilities:

  • Receive data via props (never fetch it themselves)
  • Render UI elements based on received data
  • Handle UI-only state (dropdown open/closed, tabs selection, accordion expansion)
  • Define component styling and layout
  • Implement accessibility features

Key Characteristics:

  • Pure functional components (can be memoized easily)
  • No knowledge of data source (API, Context, Redux, etc.)
  • Highly reusable across different contexts
  • Easy to test with simple prop passing
  • Can be showcased in Storybook or similar tools

What Presenters DON'T Do:

  • Make API calls or network requests
  • Manage application-level state
  • Implement complex business logic
  • Directly interact with browser APIs (localStorage, etc.)

🧩 The Mental Model

Think of it like a restaurant:

  • Container = Kitchen: Where the food is prepared, ingredients are managed, orders are processed. You don't see it, but it's doing all the complex work.
  • Presenter = Dining Room: Where the food is beautifully presented, the ambiance is set, and customers interact. It looks great but doesn't cook anything.

🔨 Step-by-Step Refactoring: From Monolith to Clean Architecture

Let's transform our monolithic UserProfile component into a clean, maintainable architecture using the Container-Presenter pattern.

Phase 1: Create the Container Component

The container will handle all data management and business logic.

// containers/UserProfileContainer.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import UserProfilePresenter from '../components/UserProfilePresenter';

const UserProfileContainer = ({ userId }) => {
  // ========================================
  // STATE MANAGEMENT
  // ========================================
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // ========================================
  // DATA FETCHING LOGIC
  // ========================================
  const fetchUserData = async () => {
    try {
      setLoading(true);
      setError(null);

      const response = await axios.get(`/api/users/${userId}`);
      setUser(response.data);
    } catch (err) {
      setError(err.response?.data?.message || 'Failed to fetch user data');
      console.error('Error fetching user:', err);
    } finally {
      setLoading(false);
    }
  };

  const fetchUserPosts = async () => {
    try {
      const response = await axios.get(`/api/posts?userId=${userId}`);
      setPosts(response.data);
    } catch (err) {
      console.error('Error fetching posts:', err);
      // We don't block the UI if posts fail - user profile still shows
    }
  };

  // ========================================
  // BUSINESS LOGIC & EVENT HANDLERS
  // ========================================
  const handleUpdateUser = async (formData) => {
    try {
      setLoading(true);

      const response = await axios.put(`/api/users/${userId}`, formData);
      setUser(response.data);

      return { success: true };
    } catch (err) {
      console.error('Error updating user:', err);
      return { 
        success: false, 
        error: err.response?.data?.message || 'Failed to update profile' 
      };
    } finally {
      setLoading(false);
    }
  };

  const handleRetry = () => {
    setError(null);
    fetchUserData();
    fetchUserPosts();
  };

  const handleDeletePost = async (postId) => {
    try {
      await axios.delete(`/api/posts/${postId}`);
      setPosts(prevPosts => prevPosts.filter(post => post.id !== postId));
      return { success: true };
    } catch (err) {
      console.error('Error deleting post:', err);
      return { 
        success: false, 
        error: 'Failed to delete post' 
      };
    }
  };

  // ========================================
  // SIDE EFFECTS
  // ========================================
  useEffect(() => {
    if (userId) {
      fetchUserData();
      fetchUserPosts();
    }
  }, [userId]);

  // ========================================
  // RENDER (Minimal JSX - Just passes data down)
  // ========================================
  return (
    <UserProfilePresenter
      user={user}
      posts={posts}
      loading={loading}
      error={error}
      onRetry={handleRetry}
      onUpdateUser={handleUpdateUser}
      onDeletePost={handleDeletePost}
    />
  );
};

export default UserProfileContainer;
Enter fullscreen mode Exit fullscreen mode

Key Observations:

  • 60 lines focused purely on data and logic
  • No CSS classes, no complex JSX trees
  • Clear separation of concerns with comments
  • Returns a single presenter component
  • All callback functions are exposed as props

Phase 2: Create the Main Presenter Component

Now let's build the presenter that handles all UI rendering.

// components/UserProfilePresenter.jsx
import { useState } from 'react';
import LoadingSpinner from './common/LoadingSpinner';
import ErrorMessage from './common/ErrorMessage';
import ProfileHeader from './profile/ProfileHeader';
import PostList from './post/PostList';
import './UserProfilePresenter.css';

const UserProfilePresenter = ({
  user,
  posts,
  loading,
  error,
  onRetry,
  onUpdateUser,
  onDeletePost
}) => {
  // ========================================
  // UI-ONLY STATE (Edit mode & form data)
  // ========================================
  const [isEditing, setIsEditing] = useState(false);
  const [formData, setFormData] = useState({ name: '', email: '', bio: '' });
  const [formError, setFormError] = useState('');

  // ========================================
  // UI EVENT HANDLERS
  // ========================================
  const handleStartEdit = () => {
    if (user) {
      setIsEditing(true);
      setFormData({
        name: user.name,
        email: user.email,
        bio: user.bio || ''
      });
      setFormError('');
    }
  };

  const handleCancelEdit = () => {
    setIsEditing(false);
    setFormData({ name: '', email: '', bio: '' });
    setFormError('');
  };

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    // Clear error when user starts typing
    if (formError) setFormError('');
  };

  const handleSaveProfile = async () => {
    // Basic client-side validation
    if (!formData.name.trim()) {
      setFormError('Name is required');
      return;
    }
    if (!formData.email.trim() || !formData.email.includes('@')) {
      setFormError('Valid email is required');
      return;
    }

    const result = await onUpdateUser(formData);

    if (result.success) {
      setIsEditing(false);
      setFormError('');
    } else {
      setFormError(result.error);
    }
  };

  // ========================================
  // CONDITIONAL RENDERING (Loading & Error States)
  // ========================================
  if (loading && !user) {
    return <LoadingSpinner message="Loading user profile..." />;
  }

  if (error) {
    return (
      <ErrorMessage 
        title="Unable to Load Profile" 
        message={error} 
        onRetry={onRetry}
      />
    );
  }

  if (!user) {
    return (
      <div className="user-profile-empty">
        <p>User not found</p>
      </div>
    );
  }

  // ========================================
  // MAIN UI RENDER
  // ========================================
  return (
    <div className="user-profile">
      <ProfileHeader
        user={user}
        isEditing={isEditing}
        formData={formData}
        formError={formError}
        loading={loading}
        onStartEdit={handleStartEdit}
        onCancelEdit={handleCancelEdit}
        onInputChange={handleInputChange}
        onSaveProfile={handleSaveProfile}
      />

      <PostList 
        posts={posts} 
        onDeletePost={onDeletePost}
        isLoading={loading}
      />
    </div>
  );
};

export default UserProfilePresenter;
Enter fullscreen mode Exit fullscreen mode

Key Observations:

  • Manages UI-only state (edit mode, form data)
  • Coordinates between sub-components
  • Handles form validation (client-side)
  • No API calls—delegates to container via callbacks
  • Clean conditional rendering patterns

Phase 3: Build Reusable Sub-Components

Now let's create the smaller, focused components that make up our UI.

Loading Spinner Component

// components/common/LoadingSpinner.jsx
import './LoadingSpinner.css';

const LoadingSpinner = ({ message = 'Loading...', size = 'medium' }) => {
  return (
    <div className="loading-container">
      <div className={`spinner spinner-${size}`} role="status" aria-live="polite">
        <span className="sr-only">Loading</span>
      </div>
      {message && <p className="loading-message">{message}</p>}
    </div>
  );
};

export default LoadingSpinner;
Enter fullscreen mode Exit fullscreen mode

Error Message Component

// components/common/ErrorMessage.jsx
import './ErrorMessage.css';

const ErrorMessage = ({ 
  title = 'Something went wrong', 
  message, 
  onRetry 
}) => {
  return (
    <div className="error-container" role="alert">
      <div className="error-icon">⚠️</div>
      <h2 className="error-title">{title}</h2>
      <p className="error-message">{message}</p>
      {onRetry && (
        <button 
          onClick={onRetry} 
          className="retry-button"
          aria-label="Retry loading"
        >
          Try Again
        </button>
      )}
    </div>
  );
};

export default ErrorMessage;
Enter fullscreen mode Exit fullscreen mode

Profile Header Component

// components/profile/ProfileHeader.jsx
import './ProfileHeader.css';

const ProfileHeader = ({
  user,
  isEditing,
  formData,
  formError,
  loading,
  onStartEdit,
  onCancelEdit,
  onInputChange,
  onSaveProfile
}) => {
  return (
    <div className="profile-header">
      <div className="avatar-section">
        <img 
          src={user.avatar || '/default-avatar.png'} 
          alt={`${user.name}'s avatar`}
          className="avatar"
        />
        <span className="user-status" title={user.isOnline ? 'Online' : 'Offline'}>
          {user.isOnline ? '🟢' : ''}
        </span>
      </div>

      {isEditing ? (
        <div className="edit-form" role="form" aria-label="Edit profile form">
          {formError && (
            <div className="form-error" role="alert">
              {formError}
            </div>
          )}

          <div className="form-group">
            <label htmlFor="name">Name</label>
            <input
              id="name"
              type="text"
              name="name"
              value={formData.name}
              onChange={onInputChange}
              placeholder="Enter your name"
              disabled={loading}
              required
              aria-required="true"
            />
          </div>

          <div className="form-group">
            <label htmlFor="email">Email</label>
            <input
              id="email"
              type="email"
              name="email"
              value={formData.email}
              onChange={onInputChange}
              placeholder="Enter your email"
              disabled={loading}
              required
              aria-required="true"
            />
          </div>

          <div className="form-group">
            <label htmlFor="bio">Bio</label>
            <textarea
              id="bio"
              name="bio"
              value={formData.bio}
              onChange={onInputChange}
              placeholder="Tell us about yourself"
              rows="4"
              disabled={loading}
              maxLength="500"
            />
            <span className="character-count">
              {formData.bio.length}/500
            </span>
          </div>

          <div className="button-group">
            <button 
              onClick={onSaveProfile} 
              className="save-btn"
              disabled={loading}
              aria-label="Save profile changes"
            >
              {loading ? 'Saving...' : 'Save Changes'}
            </button>
            <button 
              onClick={onCancelEdit} 
              className="cancel-btn"
              disabled={loading}
              aria-label="Cancel editing"
            >
              Cancel
            </button>
          </div>
        </div>
      ) : (
        <div className="profile-info">
          <h1 className="user-name">{user.name}</h1>
          <p className="user-email">{user.email}</p>
          {user.bio && <p className="user-bio">{user.bio}</p>}

          <div className="profile-stats">
            <div className="stat">
              <span className="stat-value">{user.followerCount || 0}</span>
              <span className="stat-label">Followers</span>
            </div>
            <div className="stat">
              <span className="stat-value">{user.followingCount || 0}</span>
              <span className="stat-label">Following</span>
            </div>
            <div className="stat">
              <span className="stat-value">{user.postCount || 0}</span>
              <span className="stat-label">Posts</span>
            </div>
          </div>

          <button 
            onClick={onStartEdit} 
            className="edit-btn"
            aria-label="Edit profile"
          >
            ✏️ Edit Profile
          </button>
        </div>
      )}
    </div>
  );
};

export default ProfileHeader;
Enter fullscreen mode Exit fullscreen mode

Post List Component

// components/post/PostList.jsx
import { useState } from 'react';
import './PostList.css';

const PostList = ({ posts, onDeletePost, isLoading }) => {
  const [deletingPostId, setDeletingPostId] = useState(null);

  const handleDelete = async (postId) => {
    if (!window.confirm('Are you sure you want to delete this post?')) {
      return;
    }

    setDeletingPostId(postId);
    const result = await onDeletePost(postId);

    if (!result.success) {
      alert(result.error);
    }

    setDeletingPostId(null);
  };

  const formatDate = (dateString) => {
    const date = new Date(dateString);
    const now = new Date();
    const diffInDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));

    if (diffInDays === 0) return 'Today';
    if (diffInDays === 1) return 'Yesterday';
    if (diffInDays < 7) return `${diffInDays} days ago`;

    return date.toLocaleDateString('en-US', { 
      month: 'short', 
      day: 'numeric', 
      year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined 
    });
  };

  return (
    <div className="posts-section">
      <h2 className="posts-heading">Recent Posts</h2>

      {posts.length === 0 ? (
        <div className="no-posts">
          <p>No posts yet</p>
          <span className="no-posts-icon">📝</span>
        </div>
      ) : (
        <ul className="posts-list" role="list">
          {posts.map((post) => (
            <li 
              key={post.id} 
              className={`post-item ${deletingPostId === post.id ? 'deleting' : ''}`}
              role="article"
            >
              <div className="post-header">
                <h3 className="post-title">{post.title}</h3>
                <button
                  onClick={() => handleDelete(post.id)}
                  className="delete-post-btn"
                  disabled={deletingPostId === post.id || isLoading}
                  aria-label={`Delete post: ${post.title}`}
                  title="Delete post"
                >
                  {deletingPostId === post.id ? '' : '🗑️'}
                </button>
              </div>

              <p className="post-content">{post.content}</p>

              <div className="post-footer">
                <span className="post-date">
                  {formatDate(post.createdAt)}
                </span>
                {post.likeCount > 0 && (
                  <span className="post-likes">
                    ❤️ {post.likeCount}
                  </span>
                )}
                {post.commentCount > 0 && (
                  <span className="post-comments">
                    💬 {post.commentCount}
                  </span>
                )}
              </div>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default PostList;
Enter fullscreen mode Exit fullscreen mode

📁 Organized Directory Structure

Here's how the refactored code should be organized:

src/
├── containers/
│   └── UserProfileContainer.jsx       # Data & logic layer
│
├── components/
│   ├── common/                        # Reusable UI components
│   │   ├── LoadingSpinner.jsx
│   │   ├── LoadingSpinner.css
│   │   ├── ErrorMessage.jsx
│   │   └── ErrorMessage.css
│   │
│   ├── profile/                       # Profile-specific components
│   │   ├── ProfileHeader.jsx
│   │   └── ProfileHeader.css
│   │
│   ├── post/                          # Post-related components
│   │   ├── PostList.jsx
│   │   └── PostList.css
│   │
│   ├── UserProfilePresenter.jsx       # Main presenter
│   └── UserProfilePresenter.css
│
├── hooks/                             # Custom hooks (if needed)
│   └── useUserProfile.js
│
├── services/                          # API service layer
│   └── userService.js
│
└── App.jsx
Enter fullscreen mode Exit fullscreen mode

Alternative: Feature-Based Structure

For larger applications, consider grouping by feature:

src/
├── features/
│   └── user-profile/
│       ├── containers/
│       │   └── UserProfileContainer.jsx
│       ├── components/
│       │   ├── UserProfilePresenter.jsx
│       │   ├── ProfileHeader.jsx
│       │   └── PostList.jsx
│       ├── hooks/
│       │   └── useUserProfile.js
│       └── services/
│           └── userProfileService.js
│
└── components/
    └── common/                        # Shared across features
        ├── LoadingSpinner.jsx
        └── ErrorMessage.jsx
Enter fullscreen mode Exit fullscreen mode

✅ Benefits of This Refactoring

1. Dramatically Improved Testability

Testing the Container:

// __tests__/UserProfileContainer.test.jsx
import { render, waitFor } from '@testing-library/react';
import axios from 'axios';
import UserProfileContainer from '../UserProfileContainer';

jest.mock('axios');

test('fetches and passes user data to presenter', async () => {
  const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
  axios.get.mockResolvedValue({ data: mockUser });

  const { getByText } = render(<UserProfileContainer userId={1} />);

  await waitFor(() => {
    expect(axios.get).toHaveBeenCalledWith('/api/users/1');
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing the Presenter:

// __tests__/UserProfilePresenter.test.jsx
import { render, fireEvent } from '@testing-library/react';
import UserProfilePresenter from '../UserProfilePresenter';

test('renders user information correctly', () => {
  const mockUser = { name: 'Jane Doe', email: 'jane@example.com', bio: 'Developer' };
  const { getByText } = render(
    <UserProfilePresenter 
      user={mockUser} 
      posts={[]} 
      loading={false} 
      error={null}
      onRetry={jest.fn()}
      onUpdateUser={jest.fn()}
    />
  );

  expect(getByText('Jane Doe')).toBeInTheDocument();
  expect(getByText('jane@example.com')).toBeInTheDocument();
});

test('enters edit mode when edit button is clicked', () => {
  const mockUser = { name: 'Jane Doe', email: 'jane@example.com' };
  const { getByLabelText } = render(
    <UserProfilePresenter 
      user={mockUser} 
      posts={[]} 
      loading={false} 
      error={null}
      onRetry={jest.fn()}
      onUpdateUser={jest.fn()}
    />
  );

  fireEvent.click(getByLabelText('Edit profile'));
  expect(getByLabelText('Edit profile form')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

2. Effortless Reusability

The LoadingSpinner and ErrorMessage components can now be used anywhere:

// In ProductList.jsx
<LoadingSpinner message="Loading products..." size="large" />

// In Dashboard.jsx
<ErrorMessage title="Connection Lost" message="Check your internet" />
Enter fullscreen mode Exit fullscreen mode

3. Simplified Maintenance

Need to change how loading works? Update LoadingSpinner.jsx.

Need to add a new user field? Modify ProfileHeader.jsx.

Each change is isolated and predictable.

4. Better Performance Optimization

// Easy to memoize presenters
import { memo } from 'react';

const ProfileHeader = memo(({ user, isEditing, ...props }) => {
  // Component renders only when props actually change
  return (/* JSX */);
});

// Optimize callbacks in containers
import { useCallback } from 'react';

const handleUpdateUser = useCallback(async (formData) => {
  // This function reference stays stable across renders
  const response = await axios.put(`/api/users/${userId}`, formData);
  setUser(response.data);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

5. Enhanced Developer Experience

  • Faster onboarding: New developers can understand ProfileHeader.jsx in minutes
  • Parallel development: One developer works on the container, another on the presenter
  • Clear git history: Changes to logic and UI are in separate files
  • Better code reviews: Reviewers can focus on either data flow or UI presentation

6. Storybook Integration

Presenters work perfectly with design systems:

// ProfileHeader.stories.jsx
export default {
  title: 'Profile/ProfileHeader',
  component: ProfileHeader,
};

export const Default = {
  args: {
    user: { name: 'John Doe', email: 'john@example.com', bio: 'Developer' },
    isEditing: false,
    onStartEdit: () => console.log('Edit clicked'),
  },
};

export const EditMode = {
  args: {
    user: { name: 'John Doe', email: 'john@example.com' },
    isEditing: true,
    formData: { name: 'John Doe', email: 'john@example.com', bio: '' },
    onSaveProfile: () => console.log('Save clicked'),
  },
};
Enter fullscreen mode Exit fullscreen mode

🎓 Advanced Patterns & Variations

Pattern 1: Custom Hooks for Shared Logic

You can extract container logic into reusable hooks:

// hooks/useUserProfile.js
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';

export const useUserProfile = (userId) => {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUserData = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const response = await axios.get(`/api/users/${userId}`);
      setUser(response.data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [userId]);

  const updateUser = useCallback(async (formData) => {
    try {
      const response = await axios.put(`/api/users/${userId}`, formData);
      setUser(response.data);
      return { success: true };
    } catch (err) {
      return { success: false, error: err.message };
    }
  }, [userId]);

  useEffect(() => {
    if (userId) {
      fetchUserData();
    }
  }, [userId, fetchUserData]);

  return {
    user,
    posts,
    loading,
    error,
    updateUser,
    refetch: fetchUserData,
  };
};

// Then in your container:
const UserProfileContainer = ({ userId }) => {
  const { user, posts, loading, error, updateUser, refetch } = useUserProfile(userId);

  return (
    <UserProfilePresenter
      user={user}
      posts={posts}
      loading={loading}
      error={error}
      onUpdateUser={updateUser}
      onRetry={refetch}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Service Layer for API Calls

Create a dedicated service for all user-related API operations:

// services/userService.js
import axios from 'axios';

const API_BASE = '/api';

export const userService = {
  getUser: async (userId) => {
    const response = await axios.get(`${API_BASE}/users/${userId}`);
    return response.data;
  },

  updateUser: async (userId, data) => {
    const response = await axios.put(`${API_BASE}/users/${userId}`, data);
    return response.data;
  },

  getUserPosts: async (userId) => {
    const response = await axios.get(`${API_BASE}/posts?userId=${userId}`);
    return response.data;
  },

  deletePost: async (postId) => {
    await axios.delete(`${API_BASE}/posts/${postId}`);
  },
};

// Usage in container:
import { userService } from '../services/userService';

const fetchUserData = async () => {
  try {
    setLoading(true);
    const userData = await userService.getUser(userId);
    setUser(userData);
  } catch (err) {
    setError(err.message);
  } finally {
    setLoading(false);
  }
};
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Compound Components for Complex UIs

For very complex presenters, break them into compound components:

// components/profile/ProfileCard.jsx
const ProfileCard = ({ children }) => {
  return <div className="profile-card">{children}</div>;
};

ProfileCard.Avatar = ({ src, alt, status }) => (
  <div className="profile-avatar">
    <img src={src} alt={alt} />
    <span className={`status ${status}`}></span>
  </div>
);

ProfileCard.Info = ({ children }) => (
  <div className="profile-info">{children}</div>
);

ProfileCard.Stats = ({ followers, following, posts }) => (
  <div className="profile-stats">
    <div className="stat">
      <span>{followers}</span>
      <label>Followers</label>
    </div>
    <div className="stat">
      <span>{following}</span>
      <label>Following</label>
    </div>
    <div className="stat">
      <span>{posts}</span>
      <label>Posts</label>
    </div>
  </div>
);

// Usage:
<ProfileCard>
  <ProfileCard.Avatar src={user.avatar} alt={user.name} status="online" />
  <ProfileCard.Info>
    <h1>{user.name}</h1>
    <p>{user.email}</p>
  </ProfileCard.Info>
  <ProfileCard.Stats 
    followers={user.followerCount} 
    following={user.followingCount} 
    posts={user.postCount} 
  />
</ProfileCard>
Enter fullscreen mode Exit fullscreen mode

⚖️ When to Use (and When NOT to Use)

✅ Ideal Use Cases

1. Data-Heavy Components

  • Dashboards displaying analytics from multiple API endpoints
  • User profiles with complex data relationships
  • Product catalogs with filtering, sorting, and pagination
  • Admin panels with CRUD operations

2. Components with Complex State Management

  • Multi-step forms (wizards, checkouts)
  • Settings pages with multiple sections
  • Interactive data tables with sorting, filtering, searching
  • Real-time data displays (chat, notifications, live scores)

3. Components Requiring High Reusability

  • Design system components used across multiple features
  • Components shared between web and mobile (React Native)
  • White-label applications with multiple themes
  • Components showcased in Storybook or style guides

4. Team Collaboration Scenarios

  • Backend developers can work on containers while frontend developers handle presenters
  • Designers can iterate on presenters without touching business logic
  • Multiple teams working on the same feature simultaneously

⚠️ When NOT to Use

1. Simple, Static Components

// ❌ OVERKILL: Don't split this
const Button = ({ children, onClick }) => (
  <button onClick={onClick}>{children}</button>
);

// ❌ UNNECESSARY: No business logic here
const Badge = ({ text, color }) => (
  <span className={`badge badge-${color}`}>{text}</span>
);
Enter fullscreen mode Exit fullscreen mode

2. Deeply Nested Component Hierarchies
If you find yourself passing props through 4+ component levels, consider alternatives:

// ❌ PROP DRILLING NIGHTMARE
<Container>           // Has the data
  <Presenter>         // Passes it down
    <Section>         // Passes it down
      <SubSection>    // Passes it down
        <Item>        // Finally uses it
Enter fullscreen mode Exit fullscreen mode

Better alternatives:

  • Context API for truly global state (theme, auth, language)
  • State management libraries (Redux, Zustand, Jotai) for complex shared state
  • Composition to avoid deep nesting

3. Performance-Critical Rendering
For lists with thousands of items or real-time animations:

// ❌ Additional component layer may impact performance
{items.map(item => (
  <ItemContainer key={item.id} data={item}>
    <ItemPresenter />
  </ItemContainer>
))}

// ✅ Better: Keep it flat for virtual lists
{items.map(item => (
  <Item key={item.id} {...item} />
))}
Enter fullscreen mode Exit fullscreen mode

4. One-Off Prototypes
When you're rapidly prototyping or building a proof-of-concept, premature abstraction slows you down. Build first, refactor later when patterns emerge.


🎯 Practical Exercise: Build Your Own

To truly master this pattern, let's build a Product List feature from scratch.

Requirements Specification

Build a product catalog page with the following features:

Data Requirements

  • Fetch products from https://fakestoreapi.com/products
  • Each product has: id, title, price, description, category, image, rating

Functionality

  • Display all products in a grid layout
  • Filter by category (electronics, jewelry, men's clothing, women's clothing)
  • Sort by price (low to high, high to low)
  • Search by product name
  • Add/remove products to/from cart
  • Show loading state while fetching
  • Handle and display errors with retry option
  • Display cart summary (total items, total price)

Step 1: Plan Your Architecture

Before coding, map out your components:

ProductPageContainer (Container)
├── Manages: products state, cart state, loading, error
├── Handles: fetching, filtering, sorting, cart operations
└── Renders: ProductPagePresenter

ProductPagePresenter (Presenter)
├── SearchBar
├── FilterControls
├── SortControls
├── ProductGrid
│   └── ProductCard (for each product)
└── CartSummary
Enter fullscreen mode Exit fullscreen mode

Step 2: Implement the Container

// containers/ProductPageContainer.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import ProductPagePresenter from '../components/ProductPagePresenter';

const ProductPageContainer = () => {
  const [products, setProducts] = useState([]);
  const [filteredProducts, setFilteredProducts] = useState([]);
  const [cart, setCart] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [categories, setCategories] = useState([]);

  // Fetch products
  const fetchProducts = async () => {
    try {
      setLoading(true);
      setError(null);

      const [productsRes, categoriesRes] = await Promise.all([
        axios.get('https://fakestoreapi.com/products'),
        axios.get('https://fakestoreapi.com/products/categories')
      ]);

      setProducts(productsRes.data);
      setFilteredProducts(productsRes.data);
      setCategories(categoriesRes.data);
    } catch (err) {
      setError('Failed to load products. Please try again.');
      console.error(err);
    } finally {
      setLoading(false);
    }
  };

  // Filter by category
  const handleFilterByCategory = (category) => {
    if (category === 'all') {
      setFilteredProducts(products);
    } else {
      setFilteredProducts(products.filter(p => p.category === category));
    }
  };

  // Sort products
  const handleSort = (sortType) => {
    const sorted = [...filteredProducts];

    if (sortType === 'price-low') {
      sorted.sort((a, b) => a.price - b.price);
    } else if (sortType === 'price-high') {
      sorted.sort((a, b) => b.price - a.price);
    } else if (sortType === 'rating') {
      sorted.sort((a, b) => b.rating.rate - a.rating.rate);
    }

    setFilteredProducts(sorted);
  };

  // Search products
  const handleSearch = (query) => {
    if (!query.trim()) {
      setFilteredProducts(products);
      return;
    }

    const filtered = products.filter(product =>
      product.title.toLowerCase().includes(query.toLowerCase()) ||
      product.description.toLowerCase().includes(query.toLowerCase())
    );

    setFilteredProducts(filtered);
  };

  // Cart operations
  const handleAddToCart = (product) => {
    setCart(prevCart => {
      const existingItem = prevCart.find(item => item.id === product.id);

      if (existingItem) {
        return prevCart.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }

      return [...prevCart, { ...product, quantity: 1 }];
    });
  };

  const handleRemoveFromCart = (productId) => {
    setCart(prevCart => prevCart.filter(item => item.id !== productId));
  };

  const handleUpdateQuantity = (productId, quantity) => {
    if (quantity <= 0) {
      handleRemoveFromCart(productId);
      return;
    }

    setCart(prevCart =>
      prevCart.map(item =>
        item.id === productId ? { ...item, quantity } : item
      )
    );
  };

  useEffect(() => {
    fetchProducts();
  }, []);

  return (
    <ProductPagePresenter
      products={filteredProducts}
      cart={cart}
      categories={categories}
      loading={loading}
      error={error}
      onRetry={fetchProducts}
      onFilterByCategory={handleFilterByCategory}
      onSort={handleSort}
      onSearch={handleSearch}
      onAddToCart={handleAddToCart}
      onRemoveFromCart={handleRemoveFromCart}
      onUpdateQuantity={handleUpdateQuantity}
    />
  );
};

export default ProductPageContainer;
Enter fullscreen mode Exit fullscreen mode

Step 3: Implement the Presenter

// components/ProductPagePresenter.jsx
import { useState } from 'react';
import LoadingSpinner from './common/LoadingSpinner';
import ErrorMessage from './common/ErrorMessage';
import SearchBar from './products/SearchBar';
import FilterControls from './products/FilterControls';
import SortControls from './products/SortControls';
import ProductGrid from './products/ProductGrid';
import CartSummary from './products/CartSummary';
import './ProductPagePresenter.css';

const ProductPagePresenter = ({
  products,
  cart,
  categories,
  loading,
  error,
  onRetry,
  onFilterByCategory,
  onSort,
  onSearch,
  onAddToCart,
  onRemoveFromCart,
  onUpdateQuantity
}) => {
  const [showCart, setShowCart] = useState(false);

  if (loading) {
    return <LoadingSpinner message="Loading products..." />;
  }

  if (error) {
    return <ErrorMessage message={error} onRetry={onRetry} />;
  }

  const cartItemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
  const cartTotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);

  return (
    <div className="product-page">
      <header className="page-header">
        <h1>Product Catalog</h1>
        <button 
          className="cart-toggle"
          onClick={() => setShowCart(!showCart)}
          aria-label="Toggle cart"
        >
          🛒 Cart ({cartItemCount})
        </button>
      </header>

      <div className="controls-section">
        <SearchBar onSearch={onSearch} />
        <div className="filters-sorts">
          <FilterControls 
            categories={categories} 
            onFilterByCategory={onFilterByCategory} 
          />
          <SortControls onSort={onSort} />
        </div>
      </div>

      <div className="content-section">
        <ProductGrid 
          products={products} 
          onAddToCart={onAddToCart}
          cart={cart}
        />

        {showCart && (
          <CartSummary
            cart={cart}
            total={cartTotal}
            onRemoveFromCart={onRemoveFromCart}
            onUpdateQuantity={onUpdateQuantity}
            onClose={() => setShowCart(false)}
          />
        )}
      </div>

      {products.length === 0 && (
        <div className="no-products">
          <p>No products found matching your criteria</p>
        </div>
      )}
    </div>
  );
};

export default ProductPagePresenter;
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Supporting Components

// components/products/SearchBar.jsx
import { useState } from 'react';
import './SearchBar.css';

const SearchBar = ({ onSearch }) => {
  const [query, setQuery] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search products..."
        className="search-input"
      />
      <button type="submit" className="search-button">
        🔍 Search
      </button>
    </form>
  );
};

export default SearchBar;
Enter fullscreen mode Exit fullscreen mode
// components/products/ProductGrid.jsx
import ProductCard from './ProductCard';
import './ProductGrid.css';

const ProductGrid = ({ products, onAddToCart, cart }) => {
  return (
    <div className="product-grid">
      {products.map(product => {
        const cartItem = cart.find(item => item.id === product.id);
        const inCart = !!cartItem;
        const quantity = cartItem?.quantity || 0;

        return (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={onAddToCart}
            inCart={inCart}
            quantity={quantity}
          />
        );
      })}
    </div>
  );
};

export default ProductGrid;
Enter fullscreen mode Exit fullscreen mode
// components/products/ProductCard.jsx
import './ProductCard.css';

const ProductCard = ({ product, onAddToCart, inCart, quantity }) => {
  return (
    <div className="product-card">
      <div className="product-image">
        <img src={product.image} alt={product.title} />
        {inCart && <span className="in-cart-badge">{quantity} in cart</span>}
      </div>

      <div className="product-details">
        <h3 className="product-title">{product.title}</h3>
        <p className="product-category">{product.category}</p>

        <div className="product-rating">{product.rating.rate} ({product.rating.count} reviews)
        </div>

        <div className="product-footer">
          <span className="product-price">${product.price.toFixed(2)}</span>
          <button 
            className="add-to-cart-btn"
            onClick={() => onAddToCart(product)}
          >
            {inCart ? '+ Add More' : '🛒 Add to Cart'}
          </button>
        </div>
      </div>
    </div>
  );
};

export default ProductCard;
Enter fullscreen mode Exit fullscreen mode

🚀 Taking It Further: Next Steps

Level Up Your Skills

  1. Add TypeScript: Type your props for better IDE support and error catching
  2. Implement React Query: Replace manual data fetching with a powerful caching solution
  3. Add Unit Tests: Write comprehensive tests for both containers and presenters
  4. Build a Storybook: Showcase all your presenter components
  5. Performance Optimization: Add memoization, lazy loading, and code splitting

Advanced Challenges

  • Add pagination or infinite scroll
  • Implement wishlist functionality
  • Add product comparison feature
  • Build an admin dashboard to manage products
  • Integrate real payment processing
  • Add user authentication and order history

💭 Final Thoughts

The Container-Presenter pattern is more than a coding technique—it's a mindset about separating concerns and building maintainable software. When you think in layers (data vs. presentation), you naturally write cleaner, more testable code.

Key Takeaways

Containers handle data and logic - No UI concerns

Presenters handle UI and interactions - No data fetching

Sub-components are small and focused - Single responsibility

Reusability emerges naturally - Components become portable

Testing becomes straightforward - Mock data, not APIs

Team collaboration improves - Clear boundaries of responsibility

The Pattern Evolution

As you grow as a React developer, you'll discover that Container-Presenter forms the foundation for more advanced patterns:

  • Render Props and Higher-Order Components extend container logic
  • Compound Components enhance presenter flexibility
  • Context API solves prop drilling in containers
  • Custom Hooks extract reusable container logic

Start with Container-Presenter. Master it. Then evolve your architecture as your applications demand.


🔗 Resources & Further Reading


Ready to build better React applications? Start refactoring today! 🚀

Your code—and your team—will thank you.


Share your implementations! Join the conversation:

  • Tweet your refactored components with #ReactContainerPresenter
  • Share your code on GitHub
  • Get feedback in the Tapascript Discord

Happy coding! ⚛️

Top comments (0)