Layered data access
DAO → service → apiClient → query hook. The UI never knows the database exists, so you can swap the backend later.
When the frontend talks straight to the database (Supabase with RLS, Firebase), the API is whatever the tables happen to be. Business logic ends up in components, and moving to a real backend later means touching every screen.
Put four thin layers between the UI and the data, each with one job:
component
│ useVideo(id)
query hook ── caching, one per resource
│ apiClient.getVideoForFeed(id)
apiClient ── the app's whole API, in one discoverable object
│
service ── business logic: enrich, combine, decide
│
DAO ── the only code that knows table names
│
database
Each layer
// daos/videoDao.ts
export const videoDao = {
async getById(id: string) {
const { data, error } = await db.from("videos").select("*").eq("id", id).single();
if (error) throw error;
return data;
},
};
// services/videoService.ts
export const videoService = {
getVideoForFeed: (id: string) => videoDao.getById(id), // enrichment goes here
};
// lib/apiClient.ts
export const apiClient = {
getVideoForFeed: videoService.getVideoForFeed,
};
// hooks/useVideo.ts
export const useVideo = (id: string) =>
useQuery({
queryKey: ["video", id],
queryFn: () => apiClient.getVideoForFeed(id),
enabled: !!id,
});
Why it works
- The swap is local. Moving off Supabase to REST or GraphQL changes the
apiCliententries. Hooks and components don’t change. - Logic has one home. Services are plain functions, so they’re easy to test without React.
- The API is discoverable. Everything the app can do is in
apiClient.