Separate screens, state and services so a growing app stays easy to change and test.
Why it matters
A course project can live in one big screen file. A production app cannot: it grows to dozens of screens, several developers, offline data, sign-in and analytics, and it must keep shipping for years. Without structure, every change touches everything and every bug is hard to find. Architecture is simply deciding where each kind of code lives so that changes stay small and parts can be tested on their own.
Three layers that work for most apps
- Screens (UI). Components that render and handle user input. They read state and call actions; they do not know URLs, tokens or storage keys.
- State. What the app currently knows: the signed-in user, the cart, cached server data, form drafts. It changes only through clear actions (reducer actions, store methods or query mutations).
- Services. Code that talks to the outside world: the HTTP API client, storage, push notifications, analytics, device APIs. Each service has a small interface, such as
api.getOrders()orstorage.load(key).
Data flows one way: a screen triggers an action, the action calls a service, the service result updates state, and the screen re-renders from state.
Screen --(action)--> State logic --(call)--> Service --(HTTP/storage)--> world
^ |
+------(re-render)------+
A folder layout
There is no single correct layout, but grouping by feature scales better than grouping by file type once an app is large:
app/ # Expo Router screens (thin: compose feature components)
(tabs)/orders.tsx
orders/[id].tsx
src/
features/
orders/
OrderList.tsx # UI
useOrders.ts # state + data hook
ordersApi.ts # service calls for this feature
orders.test.ts
auth/
...
services/
apiClient.ts # base URL, auth header, retries, error mapping
storage.ts
ui/ # shared design-system components (Button, Card)
Screens in app/ stay thin: they read route params and render feature components. Anything shared lives in services/ or ui/. A feature can be deleted by deleting its folder.
Server state versus client state
The most useful distinction in modern apps is between:
- Server state: data the server owns (orders, profiles, posts). It can be stale, needs caching, refetching and offline handling. Libraries such as TanStack Query (React Query) manage it: caching by key, background refresh, retries and loading flags.
- Client state: data only the app owns (selected tab, a draft, UI toggles, the current theme). Keep it in
useState, a reducer, context or a small store such as Zustand.
Mixing them causes bugs: copying server data into a global store means you now have two sources of truth that drift apart. Let a query cache hold server data and keep the global store small.
Worked example: a thin screen
// src/features/orders/ordersApi.ts
export function makeOrdersApi(client) {
return {
list: () => client.get("/orders"),
get: (id) => client.get(`/orders/${id}`),
};
}
// src/features/orders/useOrders.ts
export function useOrders() {
return useQuery({ queryKey: ["orders"], queryFn: () => ordersApi.list() });
}
// app/(tabs)/orders.tsx
export default function OrdersScreen() {
const { data, isLoading, error, refetch } = useOrders();
if (isLoading) return <Spinner />;
if (error) return <ErrorState onRetry={refetch} />;
return <OrderList orders={data} />;
}
The screen knows nothing about URLs or tokens. The API module receives its client as an argument (dependency injection), so a test can pass a fake client that returns canned data. When the base URL or auth scheme changes, you edit one file.
Common mistakes
- Fetching inside every component. The same request runs five times and loading states disagree. Centralize data access in hooks or a query cache.
- A giant global store holding server data. It goes stale; prefer a query cache for server data.
- Business rules in JSX. Price calculations or validation buried in a component cannot be unit-tested. Move them to plain functions.
- Services that import screens. Dependencies should point inward toward services and pure logic, never from services back to UI.
- Over-engineering on day one. Start with features and a shared API client; add layers when the pain appears.
Quick recap
- Separate screens (UI), state (what the app knows) and services (talking to the world).
- Group code by feature; keep route files thin.
- Treat server state and client state differently; a query cache is a good home for server data.
- Pass dependencies in, so each layer can be tested with fakes.
Try it
Take the quiz in the Do tab, then sketch the folders for an app you would like to build.