Skip to content

Commit f5a8813

Browse files
Migrate tRPC client to TanStack React Query (#1219)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent 4f2e034 commit f5a8813

86 files changed

Lines changed: 1432 additions & 1191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/code/ARCHITECTURE.md

Lines changed: 92 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ Main Process (Node.js) Renderer Process (React)
1414
│ └── ... │ │ └── TaskService, ... │
1515
├───────────────────────┤ ├───────────────────────────┤
1616
│ tRPC Routers │ ◄─tRPC(ipcLink)─► │ tRPC Clients │
17-
│ (use DI services) │ │ ├── trpcReact (hooks) │
18-
├───────────────────────┤ │ └── trpcVanilla
17+
│ (use DI services) │ │ ├── useTRPC() (hooks) │
18+
├───────────────────────┤ │ └── trpcClient (vanilla)
1919
│ System I/O │ ├───────────────────────────┤
2020
│ (fs, git, shell) │ │ Zustand Stores (state) │
2121
│ STATELESS │ │ ├── taskStore │
@@ -166,27 +166,84 @@ export const trpcRouter = router({
166166

167167
### Using tRPC in Renderer
168168

169-
**React hooks:**
169+
There are three tRPC exports, each for a different context:
170+
171+
| Export | Where to use | Purpose |
172+
|--------|-------------|---------|
173+
| `useTRPC()` | React components/hooks | Options proxy via React context |
174+
| `trpc` | Outside React (module scope, services, stores) | Options proxy bound to the singleton `queryClient` |
175+
| `trpcClient` | Anywhere (imperative calls) | Vanilla tRPC client for direct `.query()` / `.mutate()` / `.subscribe()` |
176+
177+
**React components** use `useTRPC()` + TanStack Query hooks:
170178

171179
```typescript
172-
import { trpcReact } from "@renderer/trpc/client";
180+
import { useTRPC } from "@renderer/trpc/client";
181+
import { useMutation, useQuery } from "@tanstack/react-query";
173182

174183
function MyComponent() {
175-
// Queries
176-
const { data } = trpcReact.my.getData.useQuery({ id: "123" });
177-
178-
// Mutations
179-
const mutation = trpcReact.my.updateData.useMutation();
184+
const trpc = useTRPC();
185+
186+
// Queries — pass queryOptions() to useQuery
187+
const { data } = useQuery(
188+
trpc.my.getData.queryOptions({ id: "123" }),
189+
);
190+
191+
// Mutations — pass mutationOptions() to useMutation
192+
const mutation = useMutation(
193+
trpc.my.updateData.mutationOptions({
194+
onSuccess: () => { /* ... */ },
195+
}),
196+
);
180197
const handleUpdate = () => mutation.mutate({ id: "123", value: "new" });
181198
}
182199
```
183200

184-
**Outside React (vanilla client):**
201+
**Subscriptions** use `useSubscription` from `@trpc/tanstack-react-query`:
185202

186203
```typescript
187-
import { trpcVanilla } from "@renderer/trpc/client";
204+
import { useSubscription } from "@trpc/tanstack-react-query";
205+
206+
useSubscription(
207+
trpc.my.onItemCreated.subscriptionOptions(undefined, {
208+
onData: (item) => { /* ... */ },
209+
}),
210+
);
211+
```
212+
213+
**Cache invalidation** uses `pathFilter()` or `queryFilter()` with the query client:
188214

189-
const data = await trpcVanilla.my.getData.query({ id: "123" });
215+
```typescript
216+
const queryClient = useQueryClient();
217+
218+
// Invalidate all queries under a router path
219+
queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter());
220+
221+
// Invalidate a specific query by input
222+
queryClient.invalidateQueries(
223+
trpc.git.getCurrentBranch.queryFilter({ directoryPath: repoPath }),
224+
);
225+
226+
// Set cache data directly
227+
queryClient.setQueryData(
228+
trpc.git.getLatestCommit.queryKey({ directoryPath: repoPath }),
229+
commitData,
230+
);
231+
```
232+
233+
**Outside React** (stores, sagas, services, module-scope utilities):
234+
235+
```typescript
236+
// Imperative calls — use trpcClient
237+
import { trpcClient } from "@renderer/trpc/client";
238+
239+
const data = await trpcClient.my.getData.query({ id: "123" });
240+
await trpcClient.my.updateData.mutate({ id: "123", value: "new" });
241+
242+
// Cache operations outside React — use trpc (the module-level options proxy)
243+
import { trpc } from "@renderer/trpc";
244+
import { queryClient } from "@utils/queryClient";
245+
246+
queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter());
190247
```
191248

192249
## State Management
@@ -308,7 +365,7 @@ This pattern provides:
308365
3. **Register service** in `src/main/di/container.ts`
309366
4. **Create tRPC router** in `src/main/trpc/routers/`
310367
5. **Add router** to `src/main/trpc/router.ts`
311-
6. **Use in renderer** via `trpcReact` hooks
368+
6. **Use in renderer** via `useTRPC()` + TanStack Query hooks
312369

313370
## Events (tRPC Subscriptions)
314371

@@ -409,25 +466,32 @@ export const shellRouter = router({
409466
### 4. Subscribe in Renderer
410467

411468
```typescript
469+
import { useSubscription } from "@trpc/tanstack-react-query";
470+
471+
const trpc = useTRPC();
472+
412473
// React component - global events
413-
trpcReact.my.onItemCreated.useSubscription(undefined, {
414-
enabled: true,
415-
onData: (item) => {
416-
// item is typed as { id: string; name: string }
417-
console.log("Created:", item);
418-
},
419-
});
474+
useSubscription(
475+
trpc.my.onItemCreated.subscriptionOptions(undefined, {
476+
enabled: true,
477+
onData: (item) => {
478+
// item is typed as { id: string; name: string }
479+
},
480+
}),
481+
);
420482

421483
// React component - per-session events
422-
trpcReact.shell.onData.useSubscription(
423-
{ sessionId },
424-
{
425-
enabled: !!sessionId,
426-
onData: (event) => {
427-
// event is typed as { sessionId: string; data: string }
428-
terminal.write(event.data);
484+
useSubscription(
485+
trpc.shell.onData.subscriptionOptions(
486+
{ sessionId },
487+
{
488+
enabled: !!sessionId,
489+
onData: (event) => {
490+
// event is typed as { sessionId: string; data: string }
491+
terminal.write(event.data);
492+
},
429493
},
430-
},
494+
),
431495
);
432496
```
433497

apps/code/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@
132132
"@tiptap/react": "^3.13.0",
133133
"@tiptap/starter-kit": "^3.13.0",
134134
"@tiptap/suggestion": "^3.13.0",
135-
"@trpc/client": "^11.8.0",
136-
"@trpc/react-query": "^11.8.0",
137-
"@trpc/server": "^11.8.0",
135+
"@trpc/client": "^11.12.0",
136+
"@trpc/server": "^11.12.0",
137+
"@trpc/tanstack-react-query": "^11.12.0",
138138
"@xterm/addon-fit": "^0.10.0",
139139
"@xterm/addon-serialize": "^0.13.0",
140140
"@xterm/addon-web-links": "^0.11.0",

apps/code/src/main/services/git/schemas.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,6 @@ export const getDiffStatsOutput = diffStatsSchema;
128128
export const getCurrentBranchInput = directoryPathInput;
129129
export const getCurrentBranchOutput = z.string().nullable();
130130

131-
// getDefaultBranch schemas
132-
export const getDefaultBranchInput = directoryPathInput;
133-
export const getDefaultBranchOutput = z.string();
134-
135131
// getAllBranches schemas
136132
export const getAllBranchesInput = directoryPathInput;
137133
export const getAllBranchesOutput = z.array(z.string());

apps/code/src/main/trpc/routers/git.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ import {
2929
getCommitConventionsOutput,
3030
getCurrentBranchInput,
3131
getCurrentBranchOutput,
32-
getDefaultBranchInput,
33-
getDefaultBranchOutput,
3432
getDiffStatsInput,
3533
getDiffStatsOutput,
3634
getFileAtHeadInput,
@@ -106,11 +104,6 @@ export const gitRouter = router({
106104
.output(getCurrentBranchOutput)
107105
.query(({ input }) => getService().getCurrentBranch(input.directoryPath)),
108106

109-
getDefaultBranch: publicProcedure
110-
.input(getDefaultBranchInput)
111-
.output(getDefaultBranchOutput)
112-
.query(({ input }) => getService().getDefaultBranch(input.directoryPath)),
113-
114107
getAllBranches: publicProcedure
115108
.input(getAllBranchesInput)
116109
.output(getAllBranchesOutput)

apps/code/src/renderer/App.tsx

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { Flex, Spinner, Text } from "@radix-ui/themes";
1111
import { initializeConnectivityStore } from "@renderer/stores/connectivityStore";
1212
import { useFocusStore } from "@renderer/stores/focusStore";
1313
import { useThemeStore } from "@renderer/stores/themeStore";
14-
import { trpcReact, trpcVanilla } from "@renderer/trpc/client";
14+
import { trpcClient, useTRPC } from "@renderer/trpc/client";
15+
import { useQueryClient } from "@tanstack/react-query";
16+
import { useSubscription } from "@trpc/tanstack-react-query";
1517
import { initializePostHog } from "@utils/analytics";
1618
import { logger } from "@utils/logger";
1719
import { toast } from "@utils/toast";
@@ -22,6 +24,7 @@ import { Toaster } from "sonner";
2224
const log = logger.scope("app");
2325

2426
function App() {
27+
const trpcReact = useTRPC();
2528
const { isAuthenticated, hasCompletedOnboarding, hasCodeAccess } =
2629
useAuthStore();
2730
const isDarkMode = useThemeStore((state) => state.isDarkMode);
@@ -54,48 +57,62 @@ function App() {
5457

5558
// Global workspace error listener for toasts
5659
useEffect(() => {
57-
const subscription = trpcVanilla.workspace.onError.subscribe(undefined, {
60+
const subscription = trpcClient.workspace.onError.subscribe(undefined, {
5861
onData: (data) => {
5962
toast.error("Workspace error", { description: data.message });
6063
},
6164
});
6265
return () => subscription.unsubscribe();
6366
}, []);
6467

65-
const trpcUtils = trpcReact.useUtils();
68+
const queryClient = useQueryClient();
6669

67-
trpcReact.workspace.onPromoted.useSubscription(undefined, {
68-
onData: (data) => {
69-
void trpcUtils.workspace.getAll.invalidate();
70-
toast.info(
71-
"Task moved to worktree",
72-
`Task is now working in its own worktree on branch "${data.fromBranch}"`,
73-
);
74-
},
75-
});
76-
77-
trpcReact.workspace.onBranchChanged.useSubscription(undefined, {
78-
onData: () => {
79-
void trpcUtils.workspace.getAll.invalidate();
80-
},
81-
});
82-
83-
trpcReact.focus.onBranchRenamed.useSubscription(undefined, {
84-
onData: ({ worktreePath, newBranch }) => {
85-
useFocusStore.getState().updateSessionBranch(worktreePath, newBranch);
86-
void trpcUtils.workspace.getAll.invalidate();
87-
},
88-
});
70+
useSubscription(
71+
trpcReact.workspace.onPromoted.subscriptionOptions(undefined, {
72+
onData: (data) => {
73+
void queryClient.invalidateQueries(
74+
trpcReact.workspace.getAll.pathFilter(),
75+
);
76+
toast.info(
77+
"Task moved to worktree",
78+
`Task is now working in its own worktree on branch "${data.fromBranch}"`,
79+
);
80+
},
81+
}),
82+
);
83+
84+
useSubscription(
85+
trpcReact.workspace.onBranchChanged.subscriptionOptions(undefined, {
86+
onData: () => {
87+
void queryClient.invalidateQueries(
88+
trpcReact.workspace.getAll.pathFilter(),
89+
);
90+
},
91+
}),
92+
);
93+
94+
useSubscription(
95+
trpcReact.focus.onBranchRenamed.subscriptionOptions(undefined, {
96+
onData: ({ worktreePath, newBranch }) => {
97+
useFocusStore.getState().updateSessionBranch(worktreePath, newBranch);
98+
void queryClient.invalidateQueries(
99+
trpcReact.workspace.getAll.pathFilter(),
100+
);
101+
},
102+
}),
103+
);
89104

90105
// Auto-unfocus when user manually checks out to a different branch
91-
trpcReact.focus.onForeignBranchCheckout.useSubscription(undefined, {
92-
onData: async ({ focusedBranch, foreignBranch }) => {
93-
log.warn(
94-
`Foreign branch checkout detected: ${focusedBranch} -> ${foreignBranch}. Auto-unfocusing.`,
95-
);
96-
await useFocusStore.getState().disableFocus();
97-
},
98-
});
106+
useSubscription(
107+
trpcReact.focus.onForeignBranchCheckout.subscriptionOptions(undefined, {
108+
onData: async ({ focusedBranch, foreignBranch }) => {
109+
log.warn(
110+
`Foreign branch checkout detected: ${focusedBranch} -> ${foreignBranch}. Auto-unfocusing.`,
111+
);
112+
await useFocusStore.getState().disableFocus();
113+
},
114+
}),
115+
);
99116

100117
// Wait for authStore to hydrate, then restore session from stored tokens
101118
useEffect(() => {

0 commit comments

Comments
 (0)