Three-Layered Architecture for Front-End
4 min read

In this article I'll go through the basic principles of a software engineering architecture that is the foundation of modern frontend development.
The concepts behind are simple, yet is easy to mess things up, and the results are often a frontend codebase that is hard to maintain, feels like a mess and does not scale.
But let's start from the beginning.
What is a Three-Layered Architecture?
The Three-Layered Architecture is a long-standing pattern in software engineering, traditionally used in full client-server systems but equally applicable to modern frontend applications.
At its core, it separates an application into three distinct layers:
Presentation layer - The UI, what the user sees and interacts with
Business Logic layer - How the application behaves
Data Access layer - How data is fetched, stored, and managed
What problems does it solve?
In frontend applications, especially as they grow, concerns tend to blur: components fetch data directly, business rules leak into UI logic, and API handling becomes inconsistent.
The result is a codebase that works… until it becomes difficult to change.
The 3-layered approach introduces clear boundaries and answers directly to questions like:
"Where should I fetch data for this page or component?"
"Isn't this component too big and doing too many things at once? How should I refactor it?"
The idea is that the UI doesn’t care where data comes from, the business logic doesn’t care how it’s displayed, and data access shouldn’t care how it’s used.
This decoupling makes systems easier to: scale, test, refactor and, generally speaking, reason about.
How components should not look like
Let's start with a a bad example: how a component that is in charge of rendering a user profile does too many things at once, mixing presentation, logic and data fetching.
As can be seen, the mistake is letting the component handle directly API calls, data transformations and business logic (loading, errors and parsing):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49interface ParseUserProps { userData: UserData, } const UserProfile = () => { const [isLoading, setIsLoading] = useState(false); const [userProfile, setUserProfile] = useState<UserProfile | null>(null); // parsing logic should be moved in a hook const parseUser = useMemo(({ userData }: ParseUserProps): UserProfile => { return { firstName: userData.firstName, lastName: userData.lastName, email: userData.email, } }, [user]); useEffect(() => { // all code related to data fetching should be extracted async function fetchData() { // as can be seen, this component loading state is mixed with // data fetch logic; same is true for errors handling and user data setIsLoading(true) setError(null) try { const response = await fetch(`/api/user/${id}`) if (response.ok) { throw new Error('Resonse failed with status ', response.status); } const user = await response.json(); setUserProfile(parseUser(user)) } catch (error) { setError(error) } finally { setIsLoading(false) } } fetchData(); }, []); if (isLoading) return <Spinner /> return <UserView user={user} /> }
The presentation layer
The presentation layer is responsible for rendering the UI and handling user interactions.
In React or Vue applications, this typically includes:
components
templates / JSX
styling
event handling
And stop.
Logic should ideally be "used" or "consumed" through hooks, composables, utility functions, or passed by props.
Responsibilities
Display data
Capture user input
Trigger actions (e.g. button clicks, form submissions)
What it should look like
A clean presentation layer is declarative, predictable and just keeps it simple. There should be not be any API logic, no complex transformations nor data fetch itself.
Example:
1 2 3 4 5 6 7 8 9const UserProfile = () => { // user parsing and loading logic is handled by hooks // data fetch logic isn't even visible here const { user, isLoading } = useUserProfile() if (isLoading) return <Spinner /> return <UserView user={user} /> }
Business Logic layer
The business logic layer is where the actual behavior of your application lives. It is the most important - and most often neglected - layer in frontend applications.
To enhance reusability, this layer is typically implemented as a set of custom hooks or composables.
Responsibilities
Drive application functionalities
Apply algorithms and data transformation
Coordinate actions
Encapsulate use cases
Some examples are:
filtering and sorting data
combining multiple API responses
handling permissions
orchestrating workflows
Without a clear business layer logic spreads across components, rules are duplicated and behavior becomes inconsistent.
What it should look like
In modern frontend stacks, this layer often takes the form of:
custom hooks (React)
composables (Vue)
service modules
Example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38const useUserProfile = () => { // user, loading and error states const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // parse function encapsulated - direct concern of this hook const parseUser = useMemo( ({ user }: { user: UserData }): UserProfile => { return { firstName: user.firstName, lastName: user.lastName, email: user.email, } }, [user]); // fetch logic handled here, but no direct contact with the API useEffect(() => { const fetchUserData = async () => { try { setIsLoading(true); const data = await fetchUser(); setUser(parseUser(data)); } catch (error) { setError(error); } finally { setLoading(false); } }; fetchData(); }, []); // returning only the data we need, no logic return { user, error, isLoading } } export default useUserProfile;
Data Access layer
The data access layer is responsible for interacting with external systems.
In short terms, it means fetching data from APIs, set/load from local storage and or from third party services.
Responsibilities
Fetch data
Send data
Handle low-level concerns (headers, endpoints, errors)
It should not contain business logic, shape data for UI needs and, generally speaking, know anything about components.
What it should look like
Let's see it an example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15export const fetchUser = async (id: string) => { try { // the hook only takes care of interfacing with the API const response = await fetch(`/api/user/${id}`) if (response.ok) { throw new Error('Resonse failed with status ', response.status) } return await response.json(); } catch (error) { // error re-thrown to be handled from caller throw new Error(`Failed to fetch data: ${error.message}`) } }
Conclusions
What I do like the most of software engineering is in how many ways the same code can be written to achieve similar results, but this also means we have plenty of ways to mess things up.
As development frameworks like Vue and React does not enforce a particular architecture, it's all on us to select the most appropriate in order to promote code that scales and it's easy to maintain in the long run.
This article explores the use of layered architecture to create highly maintainable React components and what are the common mistakes we should avoid.