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 tangled together—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 half the internet.
The real pain shows up when you need to reuse UI. You built a beautiful UserList component, but it’s hardwired to one specific API. Now marketing wants the same list design with data from a different endpoint—your options are copy-paste maintenance hell or a full refactor.
Solution
Split your components into two types: presentational components that only render UI, and container components that fetch data and manage state.
Presentational components are blissfully dumb—they receive data through props, render it, and that’s it. No fetching, no side effects, no business logic. Testing them is trivial: pass props, check output.
Container components do the dirty work: fetching data, managing state, handling errors, and passing everything down to presentational components. All the complexity lives here, isolated from rendering logic.
Now that UserList is pure presentation, you can feed it data from anywhere—REST API, GraphQL, local storage, WebSocket. Same UI, different data sources, no mocking required for tests.
Fair warning: hooks have largely replaced this pattern in modern React, but the core insight of separating “what it looks like” from “how it works” remains valuable.
Example
Here’s the split in action—a dumb presentational component that just renders, and a smart 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 [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>
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 - Container -->
<script>
import { onMount } from 'svelte';
import UserList from './UserList.svelte';
let users = [], loading = true, error = 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) => navigate(`/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 { 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—they don’t care where data comes from.
- Testing becomes trivial: pass props, check output, no mocking required. 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, 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 the core insight—separating what things look like from how they work—remains valuable.