React Application Architecture: Separate UI, State, and Data

Keep React components readable by separating how the interface looks, what changes during a session, and what lives on the server.

8 min read

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.

ConcernWhat it holdsWhere it lives
UIMarkup, styles, layoutPresentational components
StateUser changes, local valuesOne owning component
DataServer records, remote truthFetch 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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

App.jsxApp.jsx
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.

Data flow in a separated React page

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

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.
RunePowered by Rune AI

Frequently Asked Questions

Is this the same as MVC?

It shares the idea of separating concerns, but React does not enforce Model-View-Controller. UI, state, and data are a practical boundary you choose when a component grows too large.

Do I need a state library to separate state?

No. Most apps start with useState and lifting state up. Reach for a state library or query library only when sharing that state between many components becomes painful.

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.