A Model/API Layering Architecture for React and TypeScript Projects
Foreword
Recently, while leading a team on frontend projects, I noticed a common pitfall among junior frontend developers: they grab the requirements and immediately start writing components, then pile on state, and then start calling APIs. As the project matures, the code is littered with any types, duplicate interface definitions, and data flows that no one can decipher.
This article uses a seemingly simple RGB Color Picker project as an example to discuss a lightweight frontend architecture approach I've refined in real projects—model/api layering + TypeScript type constraints.
Project source code: GitHub | Tech stack: React 19 + TypeScript 6 + Vite 8
1. First, the Finished Product
Our Color Picker looks like this:
Three sliders control the RGB channels (0-255) respectively. The color block at the top previews the current color in real-time, and below it is a member list that simulates an asynchronous request.
Although the functionality is simple, the code structure is anything but "simple"—it's an engineering template reusable for large-scale projects.
2. Why Choose TypeScript?
If you've worked on a medium-sized project with more than 10 React components, you've likely encountered this scenario:
// 😱 The nightmare without type constraints
function ColorBrowser({ color }) {
// What is the structure of color? Is red a number or a string?
// What happens if the wrong type is passed?
return <div style={{ backgroundColor: `rgb(${color.red}, ...)` }} />
}
The core problem TypeScript solves is not "syntax checking," but "cognitive alignment in team collaboration."
When a project has more than 5 developers and over 100 components, the type system becomes living documentation—you don't need to read the README or ask a colleague; the IDE will directly tell you what the props look like.
3. The Model Layer: The "Single Source of Truth" for Frontend Data Models
3.1 What is a Model?
In traditional frontend architectures, we are accustomed to placing the "data model" on the backend. But modern frontend applications are increasingly complex, and the frontend also needs its own domain models.
In my architecture, the model/ directory is specifically for TypeScript interface definitions—it is the "constitution" for all frontend data structures.
src/
├── model/
│ ├── color.ts # Color data model
│ └── member.ts # Member data model
├── api/
│ └── memberApi.ts # API layer
├── components/
│ ├── ColorPicker.tsx
│ ├── ColorBrowser.tsx
│ └── MemberTable.tsx
└── App.tsx
3.2 Defining the Color Model
// src/model/color.ts
// Data interface—referenced in multiple places throughout the project
// model is one of the core directories of the project architecture
export interface Color {
red: number;
green: number;
blue: number;
}
That's it. But its value lies in:
- Define once, reference globally—5 components, 10 components all use the same
Colortype - Change the type, change it everywhere—if RGB is extended to RGBA in the future, just add
alpha?: numberin this one place - IDE autocompletion and type checking—misspelled a field name? It won't compile.
3.3 Defining the Member Model
// src/model/member.ts
export interface MemberEntity {
id: number;
login: string;
avatar_url: string;
}
Equally concise, equally important. This interface constrains both the data format returned by the API and the data format required for component rendering.
Core insight: A model is not "I define whatever the backend interface returns," but rather "what data the frontend needs, I define that model." The field names from the backend interface and the frontend model can differ—that's the job of the API layer (not expanded upon in this article).
4. The API Layer: Unified Interface Management, Goodbye Scattered Fetches
4.1 Why Do We Need an API Layer?
How many projects write API calls like this:
// ❌ API calls scattered inside components
function MemberTable() {
useEffect(() => {
fetch('/api/members')
.then(res => res.json())
.then(data => setMembers(data)) // What type is data? No idea.
}, [])
}
The problems are obvious:
- API endpoints are scattered across components; changing one URL requires a global search.
- Return values have no type constraints;
dataisany. - There's no way to uniformly handle errors, request interception, or caching strategies.
4.2 Practicing the API Layer
// src/api/memberApi.ts
// API file—all interfaces needed by the frontend are modularly defined here
// Unified interface declaration, easy to manage (application interface)
import { type MemberEntity } from "../model/member";
export const getMembersCollection = (): Promise<MemberEntity[]> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
id: 1457912,
login: "brauliodiez",
avatar_url: "https://avatars.githubusercontent.com/u/1457912?v=3"
},
{
id: 4374977,
login: "Nasdan",
avatar_url: "https://avatars.githubusercontent.com/u/4374977?v=3"
}
])
}, 500)
})
}
Highlights:
- Explicitly declared return type:
Promise<MemberEntity[]>—the caller doesn't need to guess. - Modular: One API file corresponds to one business domain.
- Implementation can be swapped anytime: It's mock data now; replace it with a
fetch()call later, and the components don't change a single line.
5. The Component Layer: Type-Safe Props Design
5.1 ColorPicker—The Correct Way to Open a Controlled Component
// src/components/ColorPicker.tsx
import { type Color } from '../model/color';
interface Props {
color: Color;
onColorUpdated: (color: Color) => void;
}
const ColorPicker: React.FC<Props> = (props) => {
return (
<div>
<input
type="range"
min="0"
max="255"
value={props.color.red}
onChange={event => props.onColorUpdated({
...props.color, // Spread the old state
red: +event.target.value, // Only update the red channel
})}
/>
{props.color.red}
{/* Same logic for green and blue */}
</div>
)
}
Design points:
- Controlled component pattern—State lives in the parent; ColorPicker is only responsible for display and notification.
- Immutable updates—Use the spread operator
...props.colorto copy the old value, then overwrite the modified field. +event.target.value—The+sign converts a string to a number, concise and type-safe.
5.2 ColorBrowser—A Pure Presentation Component
// src/components/ColorBrowser.tsx
interface Props {
color: Color
}
const ColorBrowser: React.FC<Props> = (props) => {
const divStyle: React.CSSProperties = {
width: "11rem",
height: "7rem",
backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`
}
return <div style={divStyle} />
}
An easily overlooked detail here: The type of divStyle is React.CSSProperties. This is not redundant—if you misspell backgroundColor as backgroudColor, TypeScript will directly throw an error.
5.3 MemberTable—Best Practices for Asynchronous Data Loading
const MemberTable: React.FC = () => {
const [memberCollection, setMemberCollection] = React.useState<MemberEntity[]>([])
React.useEffect(() => {
// Request the API after mount, won't block the first screen render
(async () => {
const members = await getMembersCollection();
setMemberCollection(members);
})()
}, [])
return (
<table>
<thead>
<tr>
<th>Avatar</th>
<th>Id</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{memberCollection.map((member: MemberEntity) => (
<MemberRow key={member.id} member={member} />
))}
</tbody>
</table>
)
}
Key techniques:
- IIFE inside useEffect—
(async () => { ... })()is a classic pattern for handling asynchronous effects (because the useEffect callback cannot be an async function directly). - Empty dependency array
[]—Executes only once when the component mounts. - Initial value is an empty array
[]—Won't throwmap of undefinedbefore rendering.
6. App.tsx—The "Central Kitchen" for State
function App() {
// TypeScript is suitable for large projects: large codebase, many team members
const [color, setColor] = useState<Color>({
red: 20,
green: 240,
blue: 180
});
return (
<>
<ColorBrowser color={color} />
<ColorPicker color={color} onColorUpdated={setColor} />
<MemberTable />
</>
)
}
Architectural intent:
- State lifting—The
colorstate lives at the App layer; ColorBrowser and ColorPicker share it via props. - Unidirectional data flow—ColorPicker does not modify
colordirectly but notifies the parent component through theonColorUpdatedcallback. setColorpassed directly as a callback—Because the signature matches perfectly:(color: Color) => void.
At this point, the entire application's data flow is very clear:
App (State Holder)
├── ColorBrowser ← color (read-only)
├── ColorPicker ← color + onColorUpdated (read/write)
└── MemberTable ← manages its own state
7. The Core Value of This Architecture
Let's review what this "small" project achieves that is typically needed for "large" projects:
| Capability | Implementation | Benefit |
|---|---|---|
| Type Safety | TypeScript + model layer | Catches 90% of field spelling and type errors at compile time |
| API Management | Unified encapsulation in the API layer | Changing an API doesn't affect components; easy mock/real switching |
| Clear Data Flow | Controlled components + state lifting | Data flow is clear at a glance; debugging is not a headache |
| Maintainability | Three-layer separation: model/api/components | A newcomer can look at the directory structure and know where the code is |
8. Conclusion: Good Architecture Is Not a "Patent for Large Projects"
Many developers think:
"I'm just writing a small demo, I don't need TypeScript, right?" "It's only 3 components, why bother with layering?"
But my view is: Good architectural habits must be deliberately practiced in small projects.
Just like this Color Picker—the functionality might take you 30 minutes to write, but by applying model/api layering + TypeScript, it becomes a directly reusable project template. Next time you get a new requirement, just fill in the code on this skeleton:
- Define new data models in
model/ - Encapsulate API methods in
api/ - Write components in
components/ - Assemble them in
App.tsx
Architecture is not a constraint; it's scaffolding—it saves you from having to rethink "where to put the code" from scratch every time.
References
- React Official Documentation—Thinking in React
- TypeScript Official Handbook—Interfaces
- Vite Official Documentation
If this article was helpful to you, feel free to like, bookmark, and comment 🎉 I will continue to update more practices on frontend architecture design.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
The GitHub link for the project source code seems to be wrong.