When I first started writing React, my components were messy. They handled fetching data, rendering the UI, and random business rules all in one place. Over time, these components got harder to read and even harder to maintain.
Then I learned the UI/Logic pattern, also called the Container/Presentation pattern, and it completely changed the way I structure React projects.
Here is the basic idea:
- Presentation (UI): Components that focus only on what things look like. They take props in and render output.
- Container (Logic): Components or hooks that handle how things work. They fetch data, manage state, run side effects, and pass ready-to-render props to the UI components.
Think of it like a stage play. The UI is the set design and costumes. The Logic is the script and the director. Each has a clear role, and together they put on a smooth show.
Why separate UI and Logic
Splitting responsibilities makes your code easier to test, reuse, and maintain.
- Testability: UI components can be tested with mock props. Logic can be tested without a DOM.
- Reusability: The same UI can be connected to different logic sources.
- Maintainability: You can change the data source without touching the markup, or change the markup without touching the data source.
When I refactored a messy dashboard this way, code reviews went from tense to relaxed because changes were smaller and easier to follow.
Level 1 — Custom hooks as the Logic layer
Hooks are a natural fit for the Logic layer. They handle fetching, state, and effects while returning a clean API to the UI layer.
function useUsers() {
const [users, setUsers] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch("/api/users")
.then(res => res.json())
.then(data => setUsers(data))
.finally(() => setLoading(false));
}, []);
return { users, loading };
}
function UserList({ users, loading }) {
if (loading) return <p>Loading…</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
export default function UsersPanel() {
const { users, loading } = useUsers();
return <UserList users={users} loading={loading} />;
}
Level 2 — Headless loader components
When you need reusable lifecycle handling like loading states and retries, a headless loader works well. It holds all the logic and lets the UI decide how to render.
function ItemsLoader({ children }) {
const [state, setState] = React.useState({ loading: true, error: null, items: [] });
React.useEffect(() => {
fetch("/api/items")
.then(res => res.json())
.then(items => setState({ loading: false, error: null, items }))
.catch(err => setState({ loading: false, error: err, items: [] }));
}, []);
return children(state);
}
function ItemsPanel() {
return (
<ItemsLoader>
{({ loading, error, items }) => {
if (loading) return <p>Loading…</p>;
if (error) return <p>Error loading items</p>;
return <ul>{items.map(i => <li key={i}>{i}</li>)}</ul>;
}}
</ItemsLoader>
);
}
Level 3 — Context for logic distribution
Context is a great way to share logic across the app without heavy prop drilling. The UI stays simple because it only receives the props it needs.
const CounterCtx = React.createContext();
function CounterProvider({ children }) {
const [count, setCount] = React.useState(0);
const value = { count, inc: () => setCount(c => c + 1), dec: () => setCount(c => c - 1) };
return <CounterCtx.Provider value={value}>{children}</CounterCtx.Provider>;
}
function CounterReadout({ value }) {
return <h4>Count: {value}</h4>;
}
function CounterButtons({ onInc, onDec }) {
return (
<div>
<button onClick={onInc}>+</button>
<button onClick={onDec}>-</button>
</div>
);
}
function CounterPanel() {
const { count, inc, dec } = React.useContext(CounterCtx);
return (
<>
<CounterReadout value={count} />
<CounterButtons onInc={inc} onDec={dec} />
</>
);
}
Level 4 — My favorite: Zustand-powered logic
I am a big fan of Zustand. It is lightweight, easy to use, and makes it simple to keep Logic separate from UI without unnecessary boilerplate.
import create from "zustand";
const useUserStore = create(set => ({
users: [],
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const res = await fetch("/api/users");
const data = await res.json();
set({ users: data, loading: false });
} catch (err) {
set({ error: err.message, loading: false });
}
},
}));
function UserList({ users, loading, error, onReload }) {
if (loading) return <p>Loading…</p>;
if (error) return <p>{error} <button onClick={onReload}>Retry</button></p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
function UsersPanel() {
const { users, loading, error, fetchUsers } = useUserStore();
React.useEffect(() => { fetchUsers(); }, [fetchUsers]);
return (
<>
<h3>Zustand-Powered Users</h3>
<UserList users={users} loading={loading} error={error} onReload={fetchUsers} />
</>
);
}
Conclusion
The UI/Logic pattern is not about following some outdated rule. It is about making your code easier to read, easier to test, and easier to change.
Logic layers can be hooks, headless components, context providers, or Zustand stores. They handle the how.
UI layers handle the what. They render whatever they are told with no hidden side effects.
Once you start separating these concerns, you will see cleaner pull requests, faster feature development, and fewer “where does this go” moments in your day.
For me, pairing this pattern with Zustand is the sweet spot. It keeps things simple, predictable, and enjoyable to work on.
Top comments (0)