Today I implemented a useTodos system in my project using Appwrite + TanStack Query, and I want to explain everything clearly — from fundamentals to real usage.
This is not just code — it’s about architecture and thinking like a developer.
🧠 First — Understand the Big Picture
Think of your app like a restaurant:
Customer (Component)
↓
Waiter (Hook)
↓
Kitchen (API layer)
↓
Database (Appwrite)
- Components never talk directly to the database
- Hooks manage logic and state
- API layer handles backend communication
👉 Each layer has one responsibility. This keeps your app scalable and clean.
📁 1. env.ts — Centralized Environment Config
const env = {
appwriteUrl: String(import.meta.env.VITE_APPWRITE_URL),
appwriteDatabaseId: String(import.meta.env.VITE_APPWRITE_DATABASE_ID),
appwriteCollectionTodos: String(import.meta.env.VITE_APPWRITE_COLLECTION_TODOS),
}
export default env
Key Concepts:
-
import.meta.env→ Access.envvariables in Vite -
String(...)→ Preventsundefinedtype issues - Central file → Avoid repetition and typos
✅ Cleaner code
✅ Easier maintenance
🔌 2. API Layer — todos.ts
This layer talks directly to Appwrite.
Fetch Todos
export const fetchTodos = async (userId: string): Promise<Todo[]> => {
const response = await databases.listDocuments({
databaseId: env.appwriteDatabaseId,
collectionId: env.appwriteCollectionTodos,
queries: [
Query.equal("userId", userId),
Query.orderDesc("$createdAt"),
Query.limit(100),
],
})
return response.documents as unknown as Todo[]
}
Why this matters:
- Filters by
userId→ security - Sorted → newest first
- Limited → performance optimized
Create Todo
export const createTodo = async (data: {
title: string
priority: "high" | "medium" | "low"
userId: string
}) => {
return databases.createDocument({
databaseId: env.appwriteDatabaseId,
collectionId: env.appwriteCollectionTodos,
documentId: ID.unique(),
data: {
...data,
completed: false,
},
})
}
Concepts:
-
ID.unique()→ auto ID generation - Union types → prevent invalid values
- Default field →
completed: false
Update Todo
export const updateTodo = async (id: string, data: Partial<Todo>) => {
return databases.updateDocument({
databaseId: env.appwriteDatabaseId,
collectionId: env.appwriteCollectionTodos,
documentId: id,
data,
})
}
👉 Partial<Todo> = update only changed fields
Delete Todo
export const deleteTodo = async (id: string) => {
await databases.deleteDocument({
databaseId: env.appwriteDatabaseId,
collectionId: env.appwriteCollectionTodos,
documentId: id,
})
}
⚛️ 3. Hook Layer — useTodos.ts
This is where TanStack Query shines.
Query Key
const TODO_KEY = ["todos"]
Think of this as a cache label.
useTodos — Fetch + Cache
export function useTodos() {
const user = useAuthStore((state) => state.user)
return useQuery({
queryKey: TODO_KEY,
queryFn: () => fetchTodos(user!.$id),
enabled: !!user,
staleTime: 1000 * 60 * 5,
})
}
Important:
-
enabled: !!user→ don't fetch if not logged in -
staleTime→ cache for 5 minutes -
user!→ safe due to enabled check
useCreateTodo
export function useCreateTodo() {
const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
return useMutation({
mutationFn: (newTodo) =>
createTodo({ ...newTodo, userId: user!.$id }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TODO_KEY })
toast.success("Todo created!")
},
})
}
Why invalidate?
👉 It tells TanStack:
"Data changed — refetch it"
useToggleTodo
export function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, completed }) =>
updateTodo(id, { completed }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TODO_KEY })
},
})
}
👉 No success toast → better UX for checkboxes
useDeleteTodo
export function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteTodo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: TODO_KEY })
toast.success("Todo deleted")
},
})
}
🧩 Real Usage in Pages
Dashboard (Read-only)
- Show stats
- No mutations
Todos Page
- Read + toggle + delete
Create Page
- Only mutation
⚡ Superpower of TanStack Query
If multiple components use:
useTodos()
👉 Only ONE API request is made
👉 Data is shared via cache
📌 Key Concepts (Quick Summary)
| Concept | Meaning |
|---|---|
| API Layer | Handles backend logic |
| Hook Layer | Handles UI state + caching |
queryKey |
Cache identifier |
enabled |
Control when query runs |
staleTime |
Cache duration |
invalidateQueries |
Refetch updated data |
Partial<T> |
Update only needed fields |
mutate() |
Trigger mutation |
isPending |
Loading state |
🧠 What I Learned
- Separation of concerns is everything
- TanStack Query removes most manual state handling
- Clean architecture makes scaling easy
- Small decisions (like query keys) matter a lot
🤖 Bonus
Sometimes I also use Claude (free version) to:
- Understand complex code
- Break down logic
- Plan project structure step-by-step
It helps speed up learning, but I always verify and implement myself.
🚀 Next Step
I’m now building:
👉 useArticles.ts using the same pattern
If you're learning React + backend integration, this pattern is a game changer.
Let’s connect and learn together 👨💻
Top comments (0)