Stop Waiting on Backend APIs: A Seven-File React Pattern for Independent Frontend Development
Everyone's heard the slogan "separation of frontend and backend," but when it comes to code, how exactly does the frontend "develop independently without relying on the backend"? The answer lies in these seven files. This article uses a Todos project to break down, file by file, how the frontend minimizes coupling with the backend through an API layer + Mock data.
A one-sentence summary of responsibilities:
| File | Responsibility |
|---|---|
main.jsx |
Start the application |
App.jsx |
Route and assign pages |
Nav.jsx |
Navigation links |
Home.jsx / Todos.jsx |
Page content |
api/config.js + api/todos.js |
Data request layer (the only coupling point between frontend and backend) |
① main.jsx: Entry point, does only one thing
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Line by line:
createRoot(...): React 18+'s new rendering entry point, replacing the oldReactDOM.render.document.getElementById('root'): Finds the<div id="root">inindex.html; the entire application renders into this.<StrictMode>: Strict mode for development, helps expose potential issues early (like side effects executing twice); automatically disabled in production.
The entry file is very thin, which is a good thing—it only handles "starting up," leaving all specific logic to App.
② App.jsx: Routing takes over everything (Key point: lazy loading)
import React, { lazy, Suspense } from 'react';
import { Routes, Route, BrowserRouter as Router } from 'react-router-dom';
import Nav from './components/Nav';
const Home = lazy(() => import('./pages/Home'));
const Todos = lazy(() => import('./pages/Todos'));
function App() {
return (
<Router>
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/todos" element={<Todos />} />
</Routes>
</Router>
)
}
export default App
1. <Router> takes over routing
<BrowserRouter>(aliased asRouter): Uses the HTML5 History API to manage the address bar URL.<Routes>+<Route>: Matches which component to render based on the current URL.path="/"rendersHome,path="/todos"rendersTodos.<Nav />is placed outside<Routes>, so the navigation bar is displayed on all pages, and only the area below switches with the route.
2. lazy + Suspense for on-demand loading (lazy loading)
const Home = lazy(() => import('./pages/Home'));
lazy(() => import(...)): Dynamic import, loads the code for a page only when navigating to it, rather than bundling and loading everything upfront.- This makes the initial homepage load faster—a user might never visit
/todos, so there's no need to download its code initially. - The accompanying
<Suspense>is used to show placeholder content while lazy loading completes (nofallbackis written here temporarily; a real project should add one, e.g.,<Suspense fallback={<div>Loading...</div>}>).
Small reminder: In the code,
<Suspense>is actually not used (thelazycomponents aren't wrapped in it). Strictly speaking, this is a point to be improved. The complete way to write it should be:<Suspense fallback={<div>Loading...</div>}> <Routes>...</Routes> </Suspense>
③ Nav.jsx: Page-level navigation
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav style={{padding:10, borderBottom:'1px solid #ccc'}}>
<Link to="/">Home</Link>
<Link to="/todos">Todos</Link>
</nav>
)
}
export default Nav
Key point: Why use <Link> instead of <a>?
<a href="/todos">refreshes the entire page, reloading all resources—a major taboo for SPAs (Single Page Applications).<Link to="/todos">only changes the address bar + triggers a route switch, without refreshing the page, providing a smooth experience.
This is one of the most basic and important habits in React Router.
④⑤ Home.jsx / Todos.jsx: Page components
Home.jsx is currently minimal:
function Home() {
return (
<>
Home
</>
)
}
export default Home
Todos.jsx is the focus—it demonstrates how a page fetches data:
import { getTodos } from '../api/todos';
import { useEffect, useState } from 'react';
function Todos() {
const [todos, setTodos] = useState([]);
useEffect(() => {
(async () => {
const data = await getTodos();
setTodos(data);
})()
}, []);
return (
<>
Todos
</>
)
}
export default Todos
Line by line:
useState([]): Declares reactive statetodos, initially an empty array.useEffect(..., []): An empty dependency array means it executes only once when the component mounts (equivalent to "send a request when the page loads").(async () => {...})(): IIFE (Immediately Invoked Function Expression). Because theuseEffectcallback cannot be directly written asasync, it wraps an immediately executed async function to allow the use ofawaitinside.await getTodos(): Calls the interface to fetch data.setTodos(data): Updates the state after getting data, triggering a re-render.
The prototype of "frontend-backend separation" is hidden here: Todos.jsx doesn't care where the data comes from, whether it's a real backend or Mock. It only calls the interface function getTodos(). No matter how the data source changes, the page code doesn't need a single line modified.
⑥ api/config.js: Axios instance, one-click baseURL switching
import axios from 'axios';
const instance = axios.create({
baseURL: '/api', // Frontend simulated request address /api/todos
// baseURL: 'http://localhost:3000', // Change this when switching to a real backend
timeout: 5000,
})
export default instance;
This is the most valuable file in the entire project, broken down line by line:
axios.create(...): Creates a pre-configured axios instance instead of usingaxios.get(...)everywhere.baseURL: '/api': All requests automatically get the/apiprefix. Soget('/todos')actually requests/api/todos.timeout: 5000: 5-second timeout, prevents requests from hanging.- The commented line
baseURL: 'http://localhost:3000'is key: Once the backend interface is truly ready, you only need to change this one line, replacing/apiwith the real backend address. All interfaces across the entire project switch automatically, without needing to change each file one by one.
Why upgrade to axios instead of using native fetch?
fetch has too few features: no timeout, no interceptors, cumbersome error handling, and requires manual .json(). axios provides instantiation, timeouts, interceptors, etc., representing the standard practice of "upgrading from fetch to axios."
⑦ api/todos.js: Unified interface management
import axios from './config';
export const getTodos = async () => {
const res = await axios.get('/todos');
return res.data;
}
This file embodies the interface management standard of "one module, one js file":
- All interfaces related to
todosare placed in this single file and exported for pages to use. - The page (
Todos.jsx) only doesimport { getTodos } from '../api/todos', without directly touching axios or URLs. - Benefit: If interfaces change in the future, or request headers need to be added, or unified error handling is required, only the api directory needs modification; the pages remain unaware.
A small detail here:
axios.get('/todos')combined withbaseURL: '/api'in config.js results in a final request to/api/todos. This/api/todosis precisely the address intercepted by Mock; the frontend and backend "connect" here.
Appendix: mock/todos.js — The key to letting the frontend "not wait foolishly for the backend"
export default [
{
url:'/api/todos',
method: 'get',
timeout: 2000,
response: (req, res) => {
return {
code: 0,
todos: [
{ id: 1, title: 'Learn frontend interface engineering', completed: true },
{ id: 2, title: 'Watch Dragon Diner', completed: false }
]
}
}
}
]
Paired with vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock';
export default defineConfig({
plugins: [react(), viteMockServe({
mockPath: 'mock', // Directory where mock files are located
localEnabled: true // Enable mock in the development environment
})],
})
Its function:
vite-plugin-mock intercepts the /api/todos request in the development server and directly returns the hardcoded fake data from mock/todos.js, bypassing the backend entirely.
Thus, the frontend has its own "fake interface" during the development phase, allowing it to independently complete the UI and get the data flow running first. Once the backend truly finishes writing /api/todos, switching the baseURL in config.js enables a seamless connection.
Tying it together: The complete chain of a single request
- User visits
/todos→App.jsxroute matches<Todos />. Todos.jsxmounts →useEffecttriggersgetTodos().api/todos.jsuses the axios instance configured byconfig.jsto requestbaseURL + '/todos'=/api/todos.- During development,
vite-plugin-mockintercepts/api/todosand returns the fake data frommock/todos.js. - Data returns to
Todos.jsx,setTodostriggers rendering.
Throughout this entire chain, the page (Todos.jsx) never knows the data is "fake" from start to finish—this is precisely the goal of frontend-backend separation.
Summary: Three things these seven files teach us
| Core Idea | Implementation Point |
|---|---|
| The key to decoupling = Interface requests | The frontend only recognizes getTodos(), not the backend |
| Unified interface management in the api directory | config.js manages configuration, todos.js manages interfaces |
| One-click baseURL switching | Mock → Real backend, only one line needs changing |
Add one more engineering methodology: The frontend should never foolishly wait for backend interfaces. First, establish the API layer + Mock data, and fully build out the UI and interactions. Once the backend interface is ready, switching one line of baseURL allows for joint debugging. This is the true meaning of "frontend-backend separation" at the code level.