Skip to content
Merged
43 changes: 43 additions & 0 deletions apps/web/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,49 @@ access the property from the result.

Reference: [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors)

### Data fetching: React Query vs direct SDK calls

Use **React Query** for data consumed primarily by React components —
it provides stale-while-revalidate, automatic background refetching,
cache sharing between components, and error/loading states. This covers
most API data: chat messages, assistant state, billing, settings, etc.

Use **direct SDK calls** inside Zustand stores for infrastructure-level
shared state that must be readable outside the React tree (middleware,
API interceptors, loaders) via `.getState()`. This applies when:

1. **Non-React consumers exist** — middleware or interceptors need the
data synchronously before any component renders.
2. **The fetch is simple** — a single call on login or on demand,
with no benefit from background refetching or cache sharing.
3. **The store is the single source of truth** — no need to sync
between React Query cache and a separate module-level variable.

Auth and organization state both fit this category. The generated SDK
client (`sdk.gen.ts`) exposes the same typed API functions that React
Query wraps, so switching from `useQuery(optionsFn())` to a direct
`apiFunction()` call uses the same endpoint, types, and interceptors.

```ts
// Infrastructure store — direct SDK call
import { organizationsList } from "@/generated/api/sdk.gen.js";

const useOrgStoreBase = create<OrgStore>()((set) => ({
organizations: [],
fetchOrganizations: async () => {
const result = await organizationsList();
set({ organizations: result.data?.results ?? [] });
},
}));

// Domain data — React Query (used only in components)
const { data } = useQuery(assistantsListOptions());
```

References:
- [TkDodo — Working with Zustand](https://tkdodo.eu/blog/working-with-zustand) — React Query maintainer's guidance on the boundary between server state (RQ) and client/infrastructure state (Zustand)
- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components)

### useReducer for component-local state only

When two or more pieces of **component-local** state change together
Expand Down
23 changes: 9 additions & 14 deletions apps/web/src/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
/**
* Root provider composition for the web SPA.
*
* Wraps the app in Organization → scope-keyed QueryClient so that:
* 1. Organization context resolves the active org for API headers.
* 2. The React Query cache is keyed by (user, org) — switching users or
* orgs yields a fresh cache instead of leaking stale data.
* Wraps the app in auth-scoped → org-scoped QueryClients so that
* switching users or orgs yields a fresh React Query cache instead of
* leaking stale data.
*
* Only third-party library providers (React Query) belong here.
* App state uses Zustand stores — see `src/stores/`.
Expand All @@ -15,10 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

import { useAuthStore } from "@/stores/auth-store.js";
import {
OrganizationProvider,
useOrganization,
} from "@/domains/organization/organization-provider.js";
import { useOrganizationStore } from "@/stores/organization-store.js";

function createQueryClient(): QueryClient {
return new QueryClient({
Expand Down Expand Up @@ -59,7 +55,8 @@ function ScopeKeyedQueryClientProvider({
}) {
const isLoggedIn = useAuthStore.use.isLoggedIn();
const user = useAuthStore.use.user();
const { currentOrganizationId } = useOrganization();
const currentOrganizationId =
useOrganizationStore.use.currentOrganizationId();
const scopeKey = `${
isLoggedIn ? `user:${user?.id ?? "unknown"}` : "anonymous"
}:org:${currentOrganizationId ?? "none"}`;
Expand All @@ -80,11 +77,9 @@ export function AppProviders({ children }: { children: ReactNode }) {

return (
<AuthScopedQueryClientProvider key={authScopeKey}>
<OrganizationProvider>
<ScopeKeyedQueryClientProvider>
{children}
</ScopeKeyedQueryClientProvider>
</OrganizationProvider>
<ScopeKeyedQueryClientProvider>
{children}
</ScopeKeyedQueryClientProvider>
</AuthScopedQueryClientProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createPortal } from "react-dom";
import { useAppRootContainer } from "@/components/app-root-context.js";
import { Button } from "@vellum/design-library";
import { Typography } from "@vellum/design-library";
import { getActiveOrganizationIdForRequests } from "@/lib/organization/organization-state.js";
import { getActiveOrganizationIdForRequests } from "@/stores/organization-store.js";

import { PdfPreview } from "@/domains/chat/components/chat-attachments/PdfPreview.js";
import { TextPreview } from "@/domains/chat/components/chat-attachments/TextPreview.js";
Expand Down
252 changes: 0 additions & 252 deletions apps/web/src/domains/organization/organization-provider.tsx

This file was deleted.

Loading