React application architecture starts with one rule: a component should not describe the interface, remember what the user changed, and talk to the server all at once. Separating UI, state, and data gives each concern one home so components stay small and predictable.
The three concerns
Split a screen into three layers before you write a component. UI is the markup and styling a user sees.
State is the small set of values that can change while the page is open. Data is the information that lives outside the page, usually on a server.
| Concern | What it holds | Where it lives |
|---|---|---|
| UI | Markup, styles, layout | Presentational components |
| State | User changes, local values | One owning component |
| Data | Server records, remote truth | Fetch layer or query library |
The boundaries matter because each layer changes for a different reason. A designer tweaks the UI, a bug fix changes state logic, and an API change touches only the data layer. When one file holds all three, every change risks the others.
Build the UI first, then add state
Start with components that only render props. They have no state and no fetch calls, so they are easy to read and easy to test.
function ProductList({ products }) {
if (products.length === 0) {
return <p>No products match.</p>;
}
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}This list does not know where products come from. It receives an array and renders it, so the same component works with a static list, a filtered list, or server data.
Keep state minimal and owned in one place
State should hold only what cannot be computed. A filtered list is not state; it is derived from the full list and the filter text during render.
import { useState } from "react";
function ProductPage({ products }) {
const [filterText, setFilterText] = useState("");
const visibleProducts = products.filter((product) =>
product.name.toLowerCase().includes(filterText.toLowerCase())
);
return (
<div>
<input
aria-label="Search products"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<ProductList products={visibleProducts} />
</div>
);
}Only the filter text is state. The visible list is calculated on every render, so it can never drift out of sync with the input.
This is the same minimal-state rule the official Thinking in React guide teaches: store the smallest set of values and compute everything else. For more on keeping state small, see Derived State in React: What Not to Store.
Move data fetching behind a boundary
Data that comes from a server belongs behind a small boundary, not inside the component that renders it. A custom Hook hides the fetch, the loading flag, and the guard that ignores stale responses.
import { useEffect, useState } from "react";
export function useProducts() {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let ignore = false;
fetch("/api/products")
.then((res) => res.json())
.then((json) => {
if (!ignore) {
setProducts(json);
setIsLoading(false);
}
});
return () => {
ignore = true;
};
}, []);
return { products, isLoading };
}The page calls this Hook at the top, shows a loading line while loading, then filters the products. When the data needs caching, retries, or invalidation, a library such as TanStack Query is the stronger choice. The split between local state and remote data is covered in Client State vs Server State in React.
One direction of data flow
When each concern has one home, data flows in one predictable direction. Server data enters through a Hook, state lives in a page component, and presentational components receive everything as props.
Events are the only path back up. A click handler calls a setter, which updates state, which re-renders the presentational components with new props. There is no hidden channel where a child writes into a parent or a component reaches across the tree.
When to split
Use this checklist when a component feels tangled.
- Split UI out when a component mixes layout, styles, and data access.
- Add state only when a value cannot be derived during render.
- Move fetching into a Hook when a component manages request lifecycles by hand.
- Lift shared state up to the closest common parent instead of duplicating it.
Do not split for its own sake. A small page component that renders a list and holds one string of state is fine exactly as it is. The separation earns its cost when the component starts to change for several reasons at once.
Rune AI
Key Insights
- UI, state, and data change for different reasons, so keep them in different places.
- Derive values during render instead of storing them in state.
- Own state in one component and pass it down as props.
- Hide data fetching behind a custom Hook or query library.
- Keep data flowing down and events flowing up.
Frequently Asked Questions
Is this the same as MVC?
Do I need a state library to separate state?
Conclusion
Separate UI, state, and data so each concern has one home. Render props in presentational components, keep changeable values in owned state, and hide remote data behind a fetch layer or query library.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.