A clean, production-style learning project demonstrating how to properly handle API calls in React using:
- Axios API layer separation
- Axios interceptors with automatic refresh-token flow
- React Query for server-state, caching & mutations
- React Hook Form + Zod for form validation
- Zustand for small client-side state
- TailwindCSS for styling
- A Node + Express mock backend (with dummy data) for easy testing
- Backend fully dockerized for plug-and-play usage
This project is ideal for developers who want to understand proper architecture for API-driven React applications.
The goal is to teach how to build React apps that communicate with APIs in a clean, scalable, production-friendly way:
- How to separate your entire API logic into a dedicated API layer
- How to configure Axios interceptors to auto-inject access tokens
- How to automatically refresh access tokens using refresh tokens
- How to avoid duplicate refresh calls using a request queue
- How to implement proper server-state management using React Query
- How to centralize validation using Zod schemas
- How to keep UI minimal and clean using TailwindCSS
src/
├─ api/ # axios client + productApi, authApi, etc.
├─ hooks/ # React Query hooks (useProductsQuery, useCreateProduct...)
├─ pages/ # ProductList, ProductCreate, ProductEdit, Login
├─ components/ # ProductCard, layout components
├─ schemas/ # zod schemas for form validation
├─ store/ # Zustand (small UI/auth state)
├─ utils/ # global error handler + token helper
└─ main.tsx
src/
├─ controllers/
├─ middlewares/ # auth middleware, error middleware
├─ services/
├─ routes/
├─ utils/ # dummyData.js, JWT helpers
├─ app.js
└─ server.js
docker-compose.yml
Dockerfile
All API calls are handled by:
Centralized axios instance:
- Base URL
- JSON headers
- Access-token injection (request interceptor)
axiosClient.interceptors.request.use((config) => { const token = getAccessToken(); // get access token from local storage if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });
- Auto refresh-token handling (response interceptor)
- Queues requests while refreshing
- Prevents infinite refresh loops
- Automatically retries failed requests after token refresh
axiosClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // ⛔️ STOP auto-refresh for login endpoint if (originalRequest?.url?.includes("/auth/login")) { return Promise.reject(error); } // If token expired if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const refreshToken = getRefreshToken(); if (!refreshToken) { window.location.href = "/login"; return Promise.reject(error); } // Prevent multiple refresh requests if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then((token) => { originalRequest.headers.Authorization = "Bearer " + token; return axiosClient(originalRequest); }) .catch((err) => Promise.reject(err)); } isRefreshing = true; try { const res = await axios.post( `${BASE_URL}/auth/refresh`, { refreshToken } ); const newToken = res.data.accessToken; setAccessToken(newToken); axiosClient.defaults.headers.common.Authorization = "Bearer " + newToken; processQueue(null, newToken); return axiosClient(originalRequest); } catch (err) { processQueue(err, null); window.location.href = "/login"; return Promise.reject(err); } finally { isRefreshing = false; } } return Promise.reject(error); }
Each file holds high-level API functions:
// productApi.ts
export default {
getAll: () => axiosClient.get("/products"),
getById: (id: string) => axiosClient.get(`/products/${id}`),
create: (data: Product) => axiosClient.post("/products", data),
update: (id: string, data: Product) => axiosClient.put(`/products/${id}`, data),
delete: (id: string) => axiosClient.delete(`/products/${id}`),
}Examples:
useLogin()
useProductsQuery()
useCreateProduct()
useUpdateProduct()
useDeleteProduct()This completely removes useEffect + manual loading/error state boilerplate.
This project implements a proper, production-ready refresh token flow:
Backend returns 401
Skips login/refresh routes
Calls /auth/refresh
Saves it → updates axios headers
User never sees an interruption
Redirect to /login and clear tokens
React Query solves server-state problems:
- Auto caching
- Automatic refetch on focus
- Mutation hooks
- Cache invalidation
- Built-in loading/error states
- Retry logic
- Devtools to inspect queries
It removes the need for:
useEffect()
useState()
loading flags
error flags
dependency arrays
Your components stay clean and UI-focused.
A simple Express API using dummy in-memory data simulates:
- JWT Login
- Access & Refresh token issuing
- Product CRUD
- Protected routes
- No database required
- Dockerized for quick start
This is perfect for frontend API learning & testing.
cd backend-api
docker compose up --buildOr run manually:
npm install
npm run devBackend runs on:
👉 http://localhost:5000
cd sample-app
npm install
npm run devFrontend runs on:
👉 http://localhost:5173
VITE_API_URL=http://localhost:5000
PORT=5000
JWT_ACCESS_SECRET=your_access_secret
JWT_REFRESH_SECRET=your_refresh_secret
ACCESS_TOKEN_EXPIRE=15m
REFRESH_TOKEN_EXPIRE=7d
| Package | Reason |
|---|---|
| react-hook-form | Fast, minimal form handling |
| zod | Strict TypeScript-first validation schemas |
| @hookform/resolvers | Connects Zod to React Hook Form |
| axios | HTTP client + interceptors for tokens |
| @tanstack/react-query | Server-state management & caching |
| @tanstack/react-query-devtools | Debug query cache in dev |
| zustand | Tiny state store for UI/auth state |
| tailwindcss | Utility-first CSS framework |
| sonner | Toast notifications |
- Add pagination with React Query
- Add optimistic updates
- Add image uploads
- Add authentication roles (admin/user)
- Switch refresh token storage to httpOnly cookies
- Add unit tests (Vitest / Jest)
- Add Docker for frontend
- Add CI/CD workflow via GitHub Actions
MIT — Free to use for learning or personal projects.