Saved
Frontend Pattern

Presentational vs. Container

Separate components that handle UI rendering from those that manage data and business logic.

Difficulty Beginner

By Den Odell

Presentational vs. Container

Problem

Components tightly couple UI rendering with data fetching, state management, and business logic, creating monolithic components that are impossible to reuse with different data sources or test independently without mocking APIs and complex setup. Changes to the API response format or business rules require modifying components that should only care about how data looks, not where it comes from. Testing UI rendering requires mocking network requests, timers, and state management, while designers cannot iterate on visual components without understanding data fetching logic.

Components become thousands of lines mixing JSX with async operations, conditional logic, error handling, and transformation code. The same UI patterns must be reimplemented when data comes from different sources like REST APIs, GraphQL, local storage, or WebSockets. Styling changes require wading through business logic to find rendering code, while unit testing becomes integration testing because components cannot be tested without their data dependencies.

Solution

Separate components that render UI (presentational or “dumb” components) from those that fetch data and manage state (container or “smart” components). Presentational components receive all data and callbacks through props, have no side effects, and focus purely on rendering UI based on inputs. Container components handle data fetching, state management, business logic, error handling, and lifecycle management, then pass data down to presentational components. This makes presentational components reusable across different data sources and easier to test in isolation with simple prop variations. Container components can be tested by verifying they fetch correct data and pass correct props, while presentational components can be tested with various data scenarios without any mocking. The pattern creates clear boundaries between “how things look” and “how things work.”

Example

This example shows separation of concerns: a presentational component that only renders UI, and a container component that handles data fetching and state management.

React Presentational and Container

// Presentational component - only renders UI
// No data fetching, no business logic, just presentation
function UserList({ users, onUserClick, loading, error }) {
  if (loading) {
    return <div className="spinner">Loading users...</div>;
  }

  if (error) {
    return <div className="error">Error: {error.message}</div>;
  }

  if (users.length === 0) {
    return <div className="empty">No users found</div>;
  }

  return (
    <ul className="user-list">
      {users.map(user => (
        <li key={user.id} onClick={() => onUserClick(user.id)}>
          <img src={user.avatar} alt="" />
          <div>
            <h3>{user.name}</h3>
            <p>{user.email}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

// Container component - handles data fetching and state
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadUsers() {
      try {
        setLoading(true);
        // Fetch data from API
        const data = await fetchUsers();
        setUsers(data);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    }

    loadUsers();
  }, []);

  const handleUserClick = async (userId) => {
    // Handle business logic
    await trackUserClick(userId);
    // Navigate or update state
    navigate(`/user/${userId}`);
  };

  // Pass data down to presentational component
  return (
    <UserList
      users={users}
      onUserClick={handleUserClick}
      loading={loading}
      error={error}
    />
  );
}

Modern Hooks Alternative

// Custom hook extracts data fetching logic
function useUsers() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function loadUsers() {
      try {
        setLoading(true);
        const data = await fetchUsers();
        if (!cancelled) {
          setUsers(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    loadUsers();
    return () => { cancelled = true; };
  }, []);

  return { users, loading, error };
}

// Presentational component stays pure
function UserList({ users, onUserClick }) {
  return (
    <ul>
      {users.map(user => (
        <li key={user.id} onClick={() => onUserClick(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}

// Component uses hook instead of container wrapper
function UsersPage() {
  const { users, loading, error } = useUsers();

  const handleUserClick = (userId) => {
    navigate(`/user/${userId}`);
  };

  if (loading) return <Spinner />;
  if (error) return <Error error={error} />;

  return <UserList users={users} onUserClick={handleUserClick} />;
}

Reusable Presentational Component

// Same presentational component works with different data sources
function ProductList({ items, onItemClick }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

// Container for API data
function APIProductsContainer() {
  const [items, setItems] = useState([]);

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

  return <ProductList items={items} onItemClick={trackClick} />;
}

// Container for cached data
function CachedProductsContainer() {
  const items = getCachedProducts();
  return <ProductList items={items} onItemClick={trackClick} />;
}

// Container for WebSocket data
function LiveProductsContainer() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    const ws = new WebSocket('/products');
    ws.onmessage = (e) => setItems(JSON.parse(e.data));
    return () => ws.close();
  }, []);

  return <ProductList items={items} onItemClick={trackClick} />;
}

Vue Presentational and Container

<!-- UserList.vue - Presentational component -->
<template>
  <div v-if="loading" class="spinner">Loading...</div>
  <div v-else-if="error" class="error">{{ error.message }}</div>
  <ul v-else class="user-list">
    <li v-for="user in users" :key="user.id" @click="$emit('user-click', user.id)">
      <img :src="user.avatar" alt="" />
      <div>
        <h3>{{ user.name }}</h3>
        <p>{{ user.email }}</p>
      </div>
    </li>
  </ul>
</template>

<script>
export default {
  props: {
    users: Array,
    loading: Boolean,
    error: Object
  },
  emits: ['user-click']
};
</script>

<!-- UserListContainer.vue - Container component -->
<template>
  <UserList
    :users="users"
    :loading="loading"
    :error="error"
    @user-click="handleUserClick"
  />
</template>

<script>
import UserList from './UserList.vue';

export default {
  components: { UserList },
  data() {
    return {
      users: [],
      loading: true,
      error: null
    };
  },
  async mounted() {
    try {
      this.users = await fetchUsers();
    } catch (err) {
      this.error = err;
    } finally {
      this.loading = false;
    }
  },
  methods: {
    async handleUserClick(userId) {
      await trackUserClick(userId);
      this.$router.push(`/user/${userId}`);
    }
  }
};
</script>

Vue Composable Alternative

<!-- useUsers.js - Composable -->
<script>
import { ref, onMounted } from 'vue';

export function useUsers() {
  const users = ref([]);
  const loading = ref(true);
  const error = ref(null);

  onMounted(async () => {
    try {
      loading.value = true;
      users.value = await fetchUsers();
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  });

  return { users, loading, error };
}
</script>

<!-- UserList.vue - Presentational -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

<script setup>
defineProps(['users']);
</script>

<!-- UsersPage.vue - Uses composable -->
<script setup>
import { useUsers } from './useUsers';
import UserList from './UserList.vue';

const { users, loading, error } = useUsers();
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error.message }}</div>
  <UserList v-else :users="users" />
</template>

Svelte Store Alternative

// userStore.js - Extracted state management
import { writable } from 'svelte/store';

export function createUserStore() {
  const { subscribe, set, update } = writable({
    users: [],
    loading: true,
    error: null
  });

  async function loadUsers() {
    try {
      update(state => ({ ...state, loading: true }));
      const users = await fetchUsers();
      set({ users, loading: false, error: null });
    } catch (error) {
      set({ users: [], loading: false, error });
    }
  }

  return {
    subscribe,
    loadUsers
  };
}

export const userStore = createUserStore();
<!-- UserList.svelte - Presentational -->
<script>
  export let users;
  export let onUserClick;
</script>

<ul>
  {#each users as user (user.id)}
    <li on:click={() => onUserClick(user.id)}>
      {user.name}
    </li>
  {/each}
</ul>

<!-- UsersPage.svelte - Uses store -->
<script>
  import { onMount } from 'svelte';
  import { userStore } from './userStore';
  import UserList from './UserList.svelte';

  onMount(() => {
    userStore.loadUsers();
  });

  function handleUserClick(userId) {
    navigate(`/user/${userId}`);
  }
</script>

{#if $userStore.loading}
  <div>Loading...</div>
{:else if $userStore.error}
  <div>Error: {$userStore.error.message}</div>
{:else}
  <UserList users={$userStore.users} onUserClick={handleUserClick} />
{/if}

Testing Presentational Components

// Easy to test - just pass props
test('renders user list', () => {
  const users = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' }
  ];

  const { getByText } = render(<UserList users={users} />);
  
  expect(getByText('Alice')).toBeInTheDocument();
  expect(getByText('Bob')).toBeInTheDocument();
});

test('shows loading state', () => {
  const { getByText } = render(<UserList users={[]} loading={true} />);
  expect(getByText('Loading users...')).toBeInTheDocument();
});

test('shows error state', () => {
  const error = new Error('Network error');
  const { getByText } = render(<UserList users={[]} error={error} />);
  expect(getByText(/Network error/)).toBeInTheDocument();
});

test('calls onClick handler', () => {
  const handleClick = jest.fn();
  const users = [{ id: 1, name: 'Alice' }];
  
  const { getByText } = render(
    <UserList users={users} onUserClick={handleClick} />
  );
  
  fireEvent.click(getByText('Alice'));
  expect(handleClick).toHaveBeenCalledWith(1);
});

Benefits

  • Makes presentational components reusable across different data sources (API, cache, WebSocket, local storage) without modification.
  • Simplifies testing by allowing pure components to be tested without mocks, network requests, or complex setup - just pass props and verify rendering.
  • Creates clear separation between UI rendering and business logic, making code easier to understand and maintain.
  • Enables designers to work on presentational components independently without understanding data fetching or business logic.
  • Presentational components become portable across projects since they have no dependencies on specific APIs or state management.
  • Facilitates Storybook development where presentational components can be documented with various prop combinations.
  • Reduces coupling between UI and data layer, making refactoring easier when API contracts change.

Tradeoffs

  • Creates additional layers of components, adding boilerplate and file organization complexity with separate files for presentational and container versions.
  • Can be over-engineering for simple components that do not need reuse - a button that’s only used once doesn’t benefit from separation.
  • Makes component trees deeper and potentially harder to navigate, requiring jumping between files to understand complete behavior.
  • Largely superseded by hooks (React), composables (Vue), and stores (Svelte) which provide better composition without wrapper components.
  • Props drilling becomes verbose when containers must pass many props through to deeply nested presentational components.
  • Deciding what belongs in container vs presentational can be ambiguous - should formatting logic live in presentational? What about derived state calculations?
  • Container components can become “prop factories” that just pass data through without adding value, creating unnecessary indirection.
  • Modern colocation strategies suggest keeping related code together rather than separating by type, contradicting this pattern.
  • Hooks enable extracting data fetching without wrapper components, reducing need for distinct container layer.
  • Testing both container and presentational separately requires more test code than testing a combined component.
  • Performance optimization through React.memo is per-component, not per-layer - presentational components still re-render if container re-renders.
  • The pattern originated before hooks existed and many codebases still use it, but new code should prefer hooks/composables/stores for better composition.
  • Strict separation can feel rigid compared to flexible hooks that allow mixing concerns when appropriate for the specific component.
Stay Updated

Get New Patterns
in Your Inbox

Join thousands of developers receiving regular insights on frontend architecture patterns

No spam. Unsubscribe anytime.