Skip to content

TopicsIntermediateUpdated Oct 2026

Maintainable React Architecture

Separate UI, state, services and converters in React features: paths for god components, API churn, feature folders and wrong dependencies.

8 min read

What you'll learn

  • Paths for greenfield features, god-component extraction, API churn and feature folders
  • Match smells like fetch-in-UI or API fields in JSX to a fix
  • Four boundaries: UI, state, services and converters
  • Feature-first folders, dependency direction and incremental refactor

React apps rarely fail because someone forgot a folder. They get hard to change when one component owns fetch, mapping, business rules and rendering at once. Clear boundaries between UI, state, services and converters make changes safer and tests narrower.

This page is about responsibility boundaries inside a feature. For folder growth, naming and lint-enforced import layers, use Project Standards. For how to test each layer, see Automated Testing. For server-state tools, see State Management.

Four layered cards labeled UI, State, Service and Converter stacked beside a messy tangled component
Maintainability comes from boundaries between responsibilities, not from more folders alone.

How to use this page

  1. Pick an architecture path if you know your situation.
  2. Match a smell if the code already hurts.
  3. Keep UI free of HTTP and API field names.
  4. Migrate one feature at a time; do not rewrite the world.

Pick an architecture path

Five short paths from an architecture hub toward greenfield, extract, API churn, feature folders and dependency goals
Start from your situation, then apply one boundary move.

Try it: architecture path

Pick your situation. You get an ordered approach, why it fits, and a first-week plan.

Goal

Your path

Draw four boundaries before the page fattens

A new feature is cheap to structure well. UI, state, service and converter stay small when you invent them early.

  1. Feature folders

    Colocate the feature before it spreads.

  2. Service layer

    One place for HTTP and external calls.

  3. Converter layer

    Map API shapes to app models once.

  4. UI layer

    Presentational components take props only.

First week

Create a feature folder with page, list component, useX hook, service and toModel converter. Keep fetch out of the list.

Tip: boundaries beat folder counts. Extract what changes for different reasons.

Checkpoint

You have a page that fetches, maps status codes and renders a list. What is the safest first extract?

Match an architecture smell

Smell chips connected to fix cards for services, converters, feature folders and dependency direction
Name the smell, then pick one primary fix.

Try it: match an architecture smell

Choose the smell you see in the codebase. The matcher points to a primary fix.

Smell

The smell

Components call fetch or axios inside useEffect and own loading plus mapping.

Start here

Service layer

Move HTTP into a named service. Let a hook coordinate loading and errors.

Also consider

State layer

Expose users, loading and error to the page.

Related: Server state tools when the hook grows

Tip: if a presentational component needs the network, the boundary is already wrong.

Key takeaway

Coupling is the real problem: multiple responsibilities change for different reasons but live in one place.

The problem is coupling

Most React apps start simply: fetch in a page, store in state, render the result. That is fine for a small feature.

Pain shows up when the same component also transforms API responses, applies business rules, owns loading and error UI, and gets copied into a second page. A change to the backend then forces edits in a file that also owns layout.

TSX
function UsersPage() {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    setLoading(true)
    fetch('/api/users')
      .then((response) => response.json())
      .then((data) => {
        setUsers(
          data.map((user) => ({
            id: user.user_id,
            name: user.full_name,
            isActive: user.status === 1,
          }))
        )
      })
      .finally(() => setLoading(false))
  }, [])

  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        users.map((user) => (
          <div key={user.id}>
            {user.name}
            {user.isActive && <span>Active</span>}
          </div>
        ))
      )}
    </div>
  )
}

Nothing here is evil for a prototype. The pattern becomes expensive when it spreads: API changes, shared user data on another route, or a test that needs the mapping without mounting the page.

Four boundaries

For a typical data-driven feature, four boundaries earn their keep:

BoundaryResponsibility
UIWhat the user sees and interacts with
StateCoordination of data, loading, errors and interactions
ServicesCommunication with APIs and other externals
ConvertersMapping between external formats and app models

The goal is not more files for their own sake. The goal is that a change has an obvious home.

The UI layer

Pages and presentational components display information and handle interaction. They should not know HTTP details, backend field names or auth header setup.

TSX
type User = {
  id: string
  name: string
  isActive: boolean
}

type UsersListProps = {
  users: User[]
  loading: boolean
}

function UsersList({ users, loading }: UsersListProps) {
  if (loading) {
    return <p>Loading users...</p>
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          {user.name}
          {user.isActive && <span>Active</span>}
        </li>
      ))}
    </ul>
  )
}

This component does not know the endpoint, how "active" is encoded on the wire, or how auth works. You can test loading, empty and populated states without a network.

Presentation decisions (badge vs text, label copy) still belong here. Business rules and infrastructure do not.

The State layer

State gets messy when fetch, transform and UI updates all live in the page. A feature hook (or server-state library) coordinates those concerns and exposes a small interface.

TSX
function useUsers() {
  const query = useQuery({
    queryKey: ['users'],
    queryFn: userService.getUsers,
  })

  return {
    users: query.data ?? [],
    loading: query.isPending,
    error: query.error,
  }
}

function UsersPage() {
  const { users, loading, error } = useUsers()

  if (error) {
    return <p>Unable to load users.</p>
  }

  return <UsersList users={users} loading={loading} />
}

Not every useState belongs in a global store. Local input, modal open state and ephemeral UI often stay in the component that uses them. Put state where it creates the least unnecessary coupling. Deeper tool choice: State Management.

State ownership

State management is about ownership and coordination, not moving every useState into a folder.

The Service layer

Services name operations the app needs from the outside world.

TSX
export const userService = {
  async getUsers() {
    const response = await apiClient.get('/users')
    return response.data
  },

  async getUserById(id: string) {
    const response = await apiClient.get(`/users/${id}`)
    return response.data
  },
}

The state layer calls userService.getUsers() instead of knowing URLs, client config or header details. Base URL changes, auth headers and HTTP client swaps stay near infrastructure.

A service is not a dumping ground for every rule. Eligibility and other domain decisions are not the same as "GET /users".

The Converter layer

APIs often speak database or legacy shapes. The UI needs an application model.

JSON
{
  "user_id": "u-101",
  "full_name": "Sarah Ahmed",
  "status": 1,
  "created_at": "2026-09-10T08:30:00Z"
}
TSX
type UserApiResponse = {
  user_id: string
  full_name: string
  status: number
  created_at: string
}

type User = {
  id: string
  name: string
  isActive: boolean
  createdAt: Date
}

function toUser(apiUser: UserApiResponse): User {
  return {
    id: apiUser.user_id,
    name: apiUser.full_name,
    isActive: apiUser.status === 1,
    createdAt: new Date(apiUser.created_at),
  }
}

export const userService = {
  async getUsers(): Promise<User[]> {
    const response = await apiClient.get<UserApiResponse[]>('/users')
    return response.data.map(toUser)
  },
}

Converters also help for form values to request payloads and for export formats. They transform data; they should not silently own pricing or permission rules.

Checkpoint

Where should user.status === 1 become isActive: true?

Connect the layers

Request flowing from UsersPage through useUsers and userService to the API, then back through toUser into UsersList
Each hop depends on a clear interface, not on implementation details scattered through the tree.

Typical request path: page → feature hook → service → API client → backend. Response path: backend → client → service → converter → app model → hook → page → presentational list.

The UI does not know how the request is made. The service does not know how a row looks. The converter does not know which route renders the result.

Organize by feature

Type-only folders (components/, hooks/, services/) work briefly, then scatter one feature across the tree. Prefer feature first, responsibility second:

Text
src/
  features/
    users/
      components/
      hooks/
      services/
      converters/
      models/
      pages/
  shared/
  app/

Small features do not need every folder on day one. Structure should reveal the architecture you have, not force one you do not need. Folder growth and lint boundaries: Project Standards. Multi-app sharing: Nx Introduction & Architecture.

Dependency direction

Separate folders fail if every layer imports every other layer. A reusable card that imports apiClient is coupled to infrastructure and hard to reuse or test.

Healthier flow:

  • Pages compose feature UI and hooks
  • Hooks coordinate services and state
  • Services use clients and converters
  • Converters map external data to app models
  • Presentational components take props

Large systems may add a domain layer that depends on neither React nor HTTP. You do not need a full Clean Architecture kit on day one. You do need intentional dependencies.

Testing by layer

Boundaries make tests smaller.

  • UI: render UsersList with props; no network
  • Converters: assert toUser mapping, including dates and status
  • Services: assert endpoints and parameters (mocked client)
  • State: assert loading, success and error coordination
TSX
it('converts an API user into an application user', () => {
  const result = toUser({
    user_id: 'u-101',
    full_name: 'Sarah Ahmed',
    status: 1,
    created_at: '2026-09-10T08:30:00Z',
  })

  expect(result).toEqual({
    id: 'u-101',
    name: 'Sarah Ahmed',
    isActive: true,
    createdAt: new Date('2026-09-10T08:30:00Z'),
  })
})

Tooling defaults and smell fixes: Automated Testing.

Incremental refactor

You do not need a rewrite.

  1. Find a feature that fetches, maps, rules and renders in one place.
  2. Extract conversion into a dedicated function or module.
  3. Move HTTP into a service; keep behavior stable.
  4. Introduce a feature hook (or server-state query) for coordination.
  5. Leave the page as composition; push presentation down.
  6. Agree where new code belongs; add lint boundaries if hope fails.
  7. Repeat on hot paths. Skip cold code that never changes.

Architecture checklist

  • Presentational components do not call the network
  • UI does not read snake_case API fields or magic status codes
  • Services name operations; pages do not hardcode URLs
  • Converters map shapes; they do not own business eligibility
  • Related feature code lives together (or has a plan to)
  • Dependency arrows point toward stable interfaces
  • New work follows the boundaries even if old files lag

Further reading

Conclusion

Maintainability is how easily people can understand, change and test behavior. UI, state, services and converters give a practical split. Feature folders keep related code discoverable. Dependency direction keeps infrastructure from soaking the tree.

Start where responsibilities are mixed. Extract the boundary that removes real pain. Grow the structure with the product.

The key takeaway

Boundaries beat folder counts. Separate what changes for different reasons, then organize so the team can find it.

Check your understanding

Test yourself6 questionsQuestion 1 of 6

What is the best first move for a greenfield data feature?

Recap

What to remember from this article

Sections read

0

Questions

0

none answered yet

Quiz

0

not taken yet

Up next: Accessibility & Semantics · 13 min

Continue