跳到正文
DEV Community· Adrij Samanta·· 3 小時前AI 評分52

IdeaPilot 的數據架構如何劃分 TanStack Query、Zustand、Firebase、NeonDB 與 Redis 的職責

TanStack Query, Zustand, Firebase, Redis and NeonDB: The Data Architecture Behind IdeaPilot

AI 導讀

IdeaPilot 將 TanStack Query、Zustand、Firebase、NeonDB 和 Redis 分別用於遠端 API 數據、用戶端狀態、身份驗證及項目資料、持久對話紀錄與語義檢索,以及活躍上下文快取。

正文

Lessons from building a React Native application for AI-powered project discovery, creation, and chat.

Building IdeaPilot taught me that choosing libraries is only part of designing an application. The more important decision is defining which layer owns each piece of data, how it changes, and where it should persist.

IdeaPilot uses React Native and Expo on the client, an Express backend for chat and resource APIs, Firebase for authentication and project-related data, and NeonDB for conversational history and semantic retrieval. TanStack Query and Zustand handle different aspects of client state, while Redis caches frequently accessed chat context.

Here is how these decisions fit together.

1. The architecture at a glance

Technology Responsibility Why it fits
TanStack Query API data, caching, pagination, mutations Manages the lifecycle of remote data
Zustand Shared UI state, drafts, selected locally persisted data Keeps client-owned state accessible across screens
Firebase Auth User authentication Provides identity tokens for authenticated backend requests
Cloud Firestore Recommended ideas, saved ideas, AI-generated ideas, custom projects Supports document-oriented, user-scoped application data
NeonDB + PostgreSQL + pgvector Chat history, messages, embeddings Combines relational storage with vector similarity search
Redis Temporary chat and project context Avoids repeated database lookups for active conversational context

These tools are not interchangeable. Each addresses a different requirement.

2. TanStack Query: Managing remote API data

Chat history, paginated messages, and project resources come from the Express API. I use TanStack Query to manage their loading states, cache lifetimes, pagination, and mutations.

For example, chat history uses a dedicated query:

export function useChatHistory(enabled: boolean) {
  return useQuery<Chat[]>({
    queryKey: ["chats"],
    queryFn: async () => {
      const { data } = await apiClient.get("/chats");
      return data;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 30,
    enabled,
  });
}

Two settings serve different purposes:

  • staleTime determines how long fetched data is considered fresh. After five minutes, the data is stale, but that alone does not trigger a refetch.
  • gcTime determines how long an inactive query remains in the cache before garbage collection.

The cache is in memory by default; these settings do not persist it across application restarts.

For chat messages, useInfiniteQuery loads pages using a cursor returned by the API:

return useInfiniteQuery<MessagePage>({
  queryKey: ["messages", chatId],
  queryFn: async ({ pageParam = null }) => {
    const { data } = await apiClient.get(
      `/chats/${chatId}/messages`,
      { params: { cursor: pageParam } },
    );
    return data;
  },
  initialPageParam: null,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  staleTime: 1000 * 60 * 5,
  gcTime: 1000 * 60 * 15,
  retry: 3,
  enabled: chatId !== "new",
});

This avoids fetching the entire conversation at once. The UI requests another page when the user scrolls towards older messages.

TanStack Query also handles optimistic updates for chat operations such as renaming, pinning, and deleting. The general sequence is to cancel conflicting refetches, snapshot the existing cache, update it immediately, restore the snapshot on failure, and invalidate the query when the mutation settles.

onMutate: async ({ chatId, title }) => {
  await queryClient.cancelQueries({
    queryKey: ["chats"],
  });

  const previousChats =
    queryClient.getQueryData<Chat[]>(["chats"]);

  queryClient.setQueryData<Chat[]>(["chats"], (old) =>
    old?.map((chat) =>
      chat.id === chatId ? { ...chat, title } : chat
    ) ?? []
  );

  return { previousChats };
},

onError: (_error, _variables, context) => {
  if (context?.previousChats) {
    queryClient.setQueryData(
      ["chats"],
      context.previousChats
    );
  }
},

onSettled: () => {
  queryClient.invalidateQueries({
    queryKey: ["chats"],
  });
},

The benefit is a responsive UI without treating the optimistic result as permanent truth. The server remains authoritative, and invalidation reconciles the cache with the server response.

3. Zustand: Managing client-owned state

Not every state change needs an API request. Form drafts, selected dashboard tabs, and other shared client state have different lifecycles from remote API data.

For example, creating a project involves multiple form steps. Zustand keeps the draft available as the user moves between them:

const useCreateProjectStore = create<CreateProject>(
  (set) => ({
    project: INITIAL_PROJECT,

    update: (values) =>
      set((state) => ({
        project: {
          ...state.project,
          ...values,
        },
      })),

    reset: () => set({ project: INITIAL_PROJECT }),
  })
);

The store allows each step to update part of the draft without passing the entire form through multiple component layers. The draft can be reset after the workflow is complete or abandoned.

For selected data that should survive application restarts, Zustand's persistence middleware can use React Native's AsyncStorage:

persist(
  (set, get) => ({
    // State and actions
  }),
  {
    name: "workspace-storage",
    storage: createJSONStorage(() => AsyncStorage),
    partialize: (state) => ({
      projects: state.projects,
    }),
  }
);

The important detail is partialize: it controls which parts of the store are persisted. Transient values such as loading indicators do not need to be written to storage.

Persistence is not synchronization, however. A stored value can become outdated, and the application still needs a strategy for refreshing data and isolating state when users sign out or switch accounts.

4. Firebase: Authentication and project-related data

Firebase serves two distinct roles in IdeaPilot.

Firebase Authentication establishes user identity. Before making a backend request, the API client obtains the current user's ID token and attaches it to the request:

apiClient.interceptors.request.use(
  async (config) => {
    const token = auth.currentUser
      ? await auth.currentUser.getIdToken()
      : null;

    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }

    return config;
  }
);

The Express backend verifies that token through Firebase Admin before allowing protected routes to proceed. This separates identity verification from individual controller logic.

Cloud Firestore stores idea and project documents. The application uses it for recommended ideas, user-generated AI ideas, saved ideas, and custom projects. User-specific records are organized under the corresponding user's document hierarchy.

Bookmarking illustrates how these layers work together. The Zustand store updates the interface immediately, while a service handles persistence:

const savedIdeaRef = doc(
  db,
  "users",
  userId,
  "savedIdeas",
  ideaId
);

if (isBookmarked) {
  await setDoc(savedIdeaRef, ideaData, { merge: true });
} else {
  await deleteDoc(savedIdeaRef);
}

The store catches a failed operation and restores its previous bookmark state. The service owns the Firestore operations; the store owns the client-facing state transition.

This separation makes each part easier to reason about. It also makes clear that a local optimistic update is not confirmation that a remote write succeeded.

5. NeonDB: Durable chat history and semantic retrieval

Chat has different data requirements from project documents. It needs ordered messages, durable conversation history, and a way to retrieve relevant context from earlier interactions.

For this, IdeaPilot uses NeonDB, a serverless PostgreSQL database, with Drizzle ORM and the pgvector extension.

The database schema stores chats and messages separately. Messages reference their chat, include a role and content, and can store a vector embedding:

export const messages = pgTable(
  "messages",
  {
    id: varchar("id", { length: 26 }).primaryKey(),
    chatId: varchar("chat_id", { length: 255 })
      .notNull()
      .references(() => chats.id, { onDelete: "cascade" }),
    senderId: varchar("sender_id", { length: 255 })
      .notNull(),
    role: roleEnum("role").notNull(),
    content: text("message_content").notNull(),
    embedding: vector("embedding", { dimensions: 768 }),
    createdAt: timestamp("created_at").defaultNow().notNull(),
  },
  (table) => [
    index("embeddingIndex").using(
      "hnsw",
      table.embedding.op("vector_cosine_ops"),
    ),
  ]
);

The HNSW index supports efficient approximate nearest-neighbour search using cosine distance. The embedding represents the semantic content of a message, allowing retrieval based on meaning rather than only exact keyword matches.

When a user sends a prompt, the backend generates an embedding and retrieves two kinds of context in parallel: the three most semantically similar messages and the four most recent messages.

const [semanticContext, recentMessages] = await Promise.all([
  db
    .select({ content: messages.content })
    .from(messages)
    .where(eq(messages.chatId, chatId))
    .orderBy(cosineDistance(messages.embedding, userEmbedding))
    .limit(3),

  db
    .select({
      role: messages.role,
      content: messages.content,
    })
    .from(messages)
    .where(eq(messages.chatId, chatId))
    .orderBy(desc(messages.id))
    .limit(4),
]);

return { semanticContext, recentMessages };

These retrieval strategies solve different problems. Recent messages preserve conversational continuity, while semantic retrieval can bring back relevant information from earlier in a longer conversation. Both are passed into the AI response-generation workflow.

After generating the response, the backend saves the user and assistant messages, including their embeddings, in a database transaction. This keeps the conversation's durable records together.

6. Redis: Caching active conversational context

NeonDB is the durable store, but not every request needs to repeat the same database lookup.

IdeaPilot uses Redis to cache lightweight chat context, including the current project ID and the latest preview message ID. The cache service follows a cache-aside pattern:

  1. Look for the context in Redis.
  2. If it is missing, retrieve it from PostgreSQL.
  3. Populate Redis with an expiration time.
  4. Update the database and refresh the cached value when the context changes.

The implementation uses a one-hour TTL for ordinary chat context and a 24-hour TTL when project context is present. Chat deletion also invalidates its cached context.

Redis is not the source of truth for the conversation. It is a performance layer in front of durable state, with a database fallback when the cache misses.

7. The separation-of-concerns principle

The most useful lesson from this architecture is that state ownership should follow data responsibility.

  • TanStack Query manages API request lifecycles, cache freshness, pagination, and server-backed mutations.
  • Zustand manages shared client state, drafts, and selected persisted data.
  • Service functions encapsulate operations such as reading and writing Firestore documents.
  • Firebase Auth establishes identity; the backend verifies the identity token.
  • NeonDB stores durable chat records and embeddings.
  • Redis temporarily caches frequently accessed conversational context.

These boundaries should remain explicit. If the same remote data is independently maintained in TanStack Query and Zustand, the application needs extra synchronization logic and risks inconsistent views. In IdeaPilot, Zustand also holds locally managed idea and bookmark collections, so those collections need deliberate refresh and rollback behavior rather than being treated as automatically synchronized server caches.

The same principle applies to persistence: storing a value locally does not make it authoritative, and caching a value does not make it durable.

Conclusion

IdeaPilot uses multiple data technologies because its data has different requirements—not simply to increase the number of tools in the stack.

TanStack Query provides a lifecycle for remote API data. Zustand keeps shared client state manageable. Firebase supports authentication and document-based project workflows, while NeonDB and pgvector support durable conversations and semantic retrieval. Redis reduces repeated lookups for active context.

The engineering decision is not just which library should I use? It is who owns this data, how does it change, and what guarantees does each layer provide?

That question leads to clearer boundaries, fewer synchronization problems, and a system that is easier to understand and evolve.

Explore the project: IdeaPilot GitHub repository

來源:DEV Community · dev.to