Presentational vs. Container
Separate components that handle UI rendering from those that manage data and business logic.
In this article
Presentational vs. Container
Problem
You open a component file to fix a CSS bug and find yourself staring at 800 lines of API calls, error handling, data transformation, and JSX all mixed together, with no clear boundary between styling logic and business logic.
I’ve reviewed components where changing a button’s color required understanding the entire data fetching pipeline. Designers couldn’t iterate on UI without running a backend, and testing meant mocking every API the component called.
It gets worse when you need to reuse the UI. You built a nice UserList component, but it’s hardwired to one specific API. Now marketing wants the same list design with data from a different endpoint, and your options are copying the component and maintaining both versions, or a full refactor.
Solution
The pattern splits components into two types: presentational components that only render UI, and container components that fetch data and manage state.
Presentational components are dumb on purpose. They receive data through props and render it, with no fetching, side effects, or business logic, so testing them means passing props and checking the output.
Container components do the rest: fetching data, managing state, handling errors, and passing everything down to presentational components. That complexity stays in the container, separate from the rendering logic.
Once UserList only handles presentation, you can feed it data from anywhere: a REST API, GraphQL, local storage, or a WebSocket. The UI stays the same whatever the data source, and its tests don’t need mocks.
Hooks have largely replaced this pattern in modern React, but separating “what it looks like” from “how it works” is still useful.
Example
Each example has a presentational component that only renders and a container that handles the data work.
Presentational and Container Pattern
// Presentational - only renders UI
function UserList({ users, onUserClick, loading, error }) {
if (loading) return <div className="spinner">Loading...</div>;
if (error) return <div className="error">{error.message}</div>;
if (users.length === 0) return <div className="empty">No users</div>;
return (
<ul className="user-list">
{users.map(user => (
<li key={user.id} onClick={() => onUserClick(user.id)}>
<img src={user.avatar} alt="" />
<h3>{user.name}</h3>
</li>
))}
</ul>
);
}
// Container - handles data fetching and state
function UserListContainer() {
const navigate = useNavigate();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUsers()
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
const handleUserClick = async (userId) => {
await trackUserClick(userId);
navigate(`/user/${userId}`);
};
return (
<UserList
users={users}
onUserClick={handleUserClick}
loading={loading}
error={error}
/>
);
} <!-- UserList.vue - Presentational -->
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="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)">
<h3>{{ user.name }}</h3>
</li>
</ul>
</template>
<script>
export default {
props: ['users', 'loading', 'error'],
emits: ['user-click']
};
</script>
<!-- UserListContainer.vue - Container -->
<template>
<UserList :users="users" :loading="loading" :error="error" @user-click="handleUserClick" />
</template>
<script>
import UserList from './UserList.vue';
export default {
components: { UserList },
data: () => ({ 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> <!-- UserList.svelte - Presentational -->
<script>
let { users, onUserClick } = $props();
</script>
<ul>
{#each users as user (user.id)}
<li onclick={() => onUserClick(user.id)}>{user.name}</li>
{/each}
</ul>
<!-- UsersPage.svelte - Container -->
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import UserList from './UserList.svelte';
let users = $state([]), loading = $state(true), error = $state(null);
onMount(async () => {
try {
users = await fetchUsers();
} catch (err) {
error = err;
} finally {
loading = false;
}
});
</script>
{#if loading}
<div>Loading...</div>
{:else if error}
<div>{error.message}</div>
{:else}
<UserList {users} onUserClick={(id) => goto(`/user/${id}`)} />
{/if} Modern Hooks Alternative
Custom hooks can replace container components by extracting data logic while keeping everything in one file:
// Custom hook extracts data logic - replaces container component
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUsers()
.then(setUsers)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { users, loading, error };
}
// Component uses hook instead of container wrapper
function UsersPage() {
const navigate = useNavigate();
const { users, loading, error } = useUsers();
if (loading) return <Spinner />;
if (error) return <Error error={error} />;
return <UserList users={users} onUserClick={(id) => navigate(`/user/${id}`)} />;
}
Reusable Presentational Component
The same presentational component works with different data sources since it only cares about props:
// Same presentational component, different data sources
function ProductList({ items, onItemClick }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>{item.name}</li>
))}
</ul>
);
}
// From API
function APIProducts() {
const [items, setItems] = useState([]);
useEffect(() => { fetchProducts().then(setItems); }, []);
return <ProductList items={items} onItemClick={trackClick} />;
}
// From WebSocket
function LiveProducts() {
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} />;
}
Testing Presentational Components
Testing becomes trivial when components just render props with no mocking required:
test('renders user list', () => {
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const { getByText } = render(<UserList users={users} />);
expect(getByText('Alice')).toBeInTheDocument();
expect(getByText('Bob')).toBeInTheDocument();
});
test('calls onClick handler', () => {
const handleClick = jest.fn();
const { getByText } = render(
<UserList users={[{ id: 1, name: 'Alice' }]} onUserClick={handleClick} />
);
fireEvent.click(getByText('Alice'));
expect(handleClick).toHaveBeenCalledWith(1);
});
Benefits
- Presentational components work with any data source (REST, GraphQL, WebSocket, cache) because nothing in them depends on where the data comes from.
- Testing is simple: you pass props and check the output, with no mocking. Storybook documentation becomes easy with different prop combinations.
- Designers can iterate on UI without understanding data fetching, and API changes only affect the container, leaving presentational components untouched.
- Components become portable across projects; that UserList works anywhere that passes it the right props.
Tradeoffs
- More files and more indirection: every feature needs at least two components, and you’re constantly jumping between them.
- Overkill for simple components; a button used in one place doesn’t need this separation.
- Hooks have largely replaced this pattern in modern React, extracting data logic without wrapper components.
- Props drilling gets tedious when containers pass fifteen props down, and boundaries get fuzzy (does date formatting belong in presentation or container?).
- Modern thinking favors colocation, keeping related code together rather than splitting by type.
Summary
Separating presentational components from containers divides your UI into dumb components that render props and smart components that manage data. The presentational layer becomes reusable and trivial to test, while containers handle coordination. Modern hooks have largely replaced explicit containers, but separating what things look like from how they work is still useful.
See also
- Separation of Concerns — Fundamentals
- Single Responsibility — Fundamentals
- Component Testing — Testing
- Smart and Dumb Components - Dan Abramov
- Thinking in React
- Component Design - Vue