Zustand bindings for tRPC clients. Manage tRPC queries, mutations and subscriptions as Zustand stores.
trpc-zustand bridges the gap between tRPC and Zustand, allowing you to use Zustand-specific patterns to manage tRPC operations and their state, including middlewares, store slicing, selectors, React integration, and more. Fully typed and compatible with tRPC v11 and Zustand v5.
Install trpc-zustand
and its peer dependencies @trpc/client
, @trpc/server
, and zustand
:
npm install trpc-zustand @trpc/client @trpc/server zustand
yarn add trpc-zustand @trpc/client @trpc/server zustand
bun add trpc-zustand @trpc/client @trpc/server zustand
Create a tRPC client with createTRPCZustand
:
import {
splitLink,
unstable_httpBatchStreamLink,
unstable_httpSubscriptionLink
} from "@trpc/client";
import { createTRPCZustand } from "trpc-zustand";
import { create } from "zustand";
import type { AppRouter } from "./server";
const trpc = createTRPCZustand<AppRouter>({
links: [
splitLink({
condition: op => op.type === "subscription",
// In case you want subscriptions:
true: unstable_httpSubscriptionLink({ url: "http://localhost:2022" }),
false: unstable_httpBatchStreamLink({ url: "http://localhost:2022" })
})
]
});
Then create Zustand stores using the client, one store per tRPC operation. The client returns Zustand state creators (the function that takes set
and get
) so it works just fine with both React- and vanilla Zustand:
import { create } from "zustand"; // React
export const useTodosStore = create(trpc.todos.getTodos.queryStore());
export const useAddTodoStore = create(trpc.todos.addTodo.mutationStore());
import { createStore } from "zustand"; // Vanilla
export const todosStore = createStore(trpc.todos.getTodos.queryStore());
export const addTodoStore = createStore(trpc.todos.addTodo.mutationStore());
You can pass initialization options to the store creator:
export const useTodosStore = create(
trpc.todos.getTodos.queryStore({ keepPreviousData: false })
);
export const useAddTodoStore = create(
trpc.todos.addTodo.mutationStore({ refetchStores: () => [useTodosStore] })
);
Here is an overview of the API that the tRPC stores expose. For an exhaustive list of methods and properties, see the API Reference.
import { createTRPCZustand } from "trpc-zustand";
import { createStore } from "zustand";
import type { AppRouter } from "./server";
const trpc = createTRPCZustand<AppRouter>({
/* ... */
});
const todosStore = createStore(trpc.todos.getTodos.queryStore());
const addTodoStore = createStore(trpc.todos.addTodo.mutationStore());
// Fetch data
todosStore.getState().query({ listId: "1" });
// Refetch data (last input)
todosStore.getState().refetch();
// Disable/enable/reset a store
todosStore.getState().disable();
todosStore.getState().enable();
todosStore.getState().reset();
// Execute a mutation
addTodoStore.getState().mutate({ listId: "1", title: "Buy milk" });
// Lookup a store's state
const { data, loading, error } = todosStore.getState();
const isAddingTodo = addTodoStore.getState().loading;
// Listen to state changes
const unsubscribe = addTodoStore.subscribe((state, prevState) => {
if (state.loading && !prevState.loading) {
console.log("Adding todo...");
}
});
Everything that works with vanilla stores works on React stores too. But in addition to that, you can use the store as a hook with or without custom selectors. The component will re-render when the selected store state changes. Note: if you only need methods, there is no need to use it as a hook as the methods never change.
import { createTRPCZustand } from "trpc-zustand";
import { create } from "zustand";
import type { AppRouter } from "./server";
const trpc = createTRPCZustand<AppRouter>({
/* ... */
});
const useTodosStore = create(trpc.todos.getTodos.queryStore());
const useAddTodoStore = create(trpc.todos.addTodo.mutationStore());
function TodoList({ listId }: { listId: string }) {
// Use the entire store
const todos = useTodosStore();
useEffect(() => {
// Fetch data
todos.query({ listId });
}, [listId]);
const addTodo = async () => {
// Execute a mutation
await useAddTodoStore.getState().mutate({ listId, title: "Buy milk" });
// Refetch data
await todos.refetch();
};
// Display query states and data
return (
<div>
{todos.loading ? (
<p>Loading...</p>
) : todos.error ? (
<p>Error: {todos.error.message}</p>
) : (
todos.data && (
<ul>
{todos.data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
)}
<button onClick={addTodo}>Add todo</button>
<Loader />
</div>
);
}
function Loader() {
// Use custom selectors
const addTodoLoading = useAddTodoStore(state => state.loading);
return <p>addTodo is {addTodoLoading ? "loading" : "not loading"}</p>;
}
Zustand middlewares will work like with any other Zustand store. The following example persists the data of the latest getTodos
query in localStorage. When you reload the page, the data will be restored without having to refetch it.
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useTodosStore = create(
persist(trpc.todos.getTodos.queryStore(), {
name: "todos",
partialize: ({ data }) => ({ data })
})
);
You can refetch a store after a mutation by passing a refetchStores
option to the mutation store:
const useTodosStore = create(trpc.todos.getTodos.queryStore());
const useAddTodoStore = create(
trpc.todos.addTodo.mutationStore({ refetchStores: () => [useTodosStore] })
);
// Will refetch the todos store after the mutation is successful
useAddTodoStore.getState().mutate({ listId: "1", title: "Buy milk" });
refetchStores
is a function that is called after the mutation is successful. Thus you can have arbitrary control over which stores are refetched and when:
const useAddTodoStore = create(
trpc.todos.addTodo.mutationStore({
refetchStores: () => {
if (
useAddTodoStore.getState().input?.listId ===
useTodosStore.getState().input?.listId
) {
return [useTodosStore];
}
}
})
);
If you want to refetch after specific mutation calls only and not as a general rule, just use promise patterns:
const addTodo = async () => {
await useAddTodoStore.getState().mutate({ listId: "1", title: "Buy milk" });
await useTodosStore.getState().refetch();
};
You can compose a tRPC store with other Zustand stores using the slice pattern, as long as the properties don't conflict.
import { create, StateCreator } from "zustand";
import { createTRPCZustand, InferStore } from "trpc-zustand";
import type { AppRouter } from "./server";
const trpc = createTRPCZustand<AppRouter>({
/* ... */
});
// Use the InferStore utility to get the store's exact state type
type DeleteTodoSlice = InferStore<typeof deleteTodoSlice>;
type AdditionalSlice = {
foo: string;
resetFoo: () => void;
};
const deleteTodoSlice = trpc.todos.deleteTodo.mutationStore();
const additionalSlice: StateCreator<
DeleteTodoSlice & AdditionalSlice,
[],
[],
AdditionalSlice
> = (set, get) => ({
foo: "bar",
resetFoo: () => set({ foo: get().loading ? "bar" : "baz" })
});
export const useDeleteTodoStore = create<DeleteTodoSlice & AdditionalSlice>(
(...a) => ({
...deleteTodoSlice(...a),
...additionalSlice(...a)
})
);
You can use the subscribe
method to start a subscription. It returns a cancellation function.
const streamTodosStore = createStore(
trpc.todos.streamTodos.subscriptionStore()
);
// Start a tRPC subscription
const stop = streamTodosStore.getState().subscribe(
{ listId },
{
onData: data => {
console.log("New todo:", data);
},
onError: error => {
console.error("Error:", error);
}
}
);
// Stop the subscription
stop();
React-style:
const useStreamTodosStore = create(trpc.todos.streamTodos.subscriptionStore());
function TodoStream({ listId }: { listId: string }) {
const { status, data, error, subscribe } = useStreamTodosStore();
useEffect(() => {
// Start a tRPC subscription
// Note: returns the cancellation function as cleanup
return subscribe({ listId });
}, [listId]);
return (
<div>
{error && <p>Error: {error.message}</p>}
<p>
({status}) Latest todo: {data?.title}
</p>
</div>
);
}
Property | Type | Description |
---|---|---|
enabled |
boolean |
Initially enable/disable the store. Optional. Defaults to true . |
keepPreviousData |
boolean |
Keep the previous data while a new query is being fetched. Optional. Defaults to true . |
Property | Type | Description |
---|---|---|
enabled |
boolean |
Tracks the store's enabled state. |
keepPreviousData |
boolean |
Tracks if the previous data should be kept while a new query is being fetched. |
input |
TInput | undefined |
The input of the last query. |
loading |
boolean |
Tracks the query's loading state. |
data |
TData | undefined |
The data returned by the last query. |
error |
TRPCClientError | undefined |
The error thrown by the last query. |
enable |
() => void |
Enables the store. |
disable |
() => void |
Disables the store. |
toggle |
() => void |
Toggles the store's enabled state. |
reset |
() => void |
Resets the store. |
query |
(input, opts?) => Promise |
Fires a query. If there is an ongoing query, it is stopped and the new one is started. |
refetch |
(opts?) => Promise |
Refetches the last query. If there is an ongoing query, it is stopped and the new one is started. |
Property | Type | Description |
---|---|---|
enabled |
boolean |
Initially enable/disable the store. Optional. Defaults to true . |
refetchStores |
() => StoreApi[] |
Calls the refetch method on the stores after the mutation is successful. Optional. |
Property | Type | Description |
---|---|---|
enabled |
boolean |
Tracks the store's enabled state. |
input |
TInput | undefined |
The input of the last mutation. |
loading |
boolean |
Tracks the mutation's loading state. |
data |
TData | undefined |
The data returned by the last mutation. |
error |
TRPCClientError | undefined |
The error thrown by the last mutation. |
enable |
() => void |
Enables the store. |
disable |
() => void |
Disables the store. |
toggle |
() => void |
Toggles the store's enabled state. |
reset |
() => void |
Resets the store. |
mutate |
(input, opts?) => Promise |
Fires a mutation. If there is an ongoing mutation, it is stopped and the new one is started. |
Property | Type | Description |
---|---|---|
enabled |
boolean |
Initially enable/disable the store. Optional. Defaults to true . |
Property | Type | Description |
---|---|---|
enabled |
boolean |
Tracks the store's enabled state. |
input |
TInput | undefined |
The input of the last subscription. |
status |
"idle" | "connecting" | "pending" | "error" |
Tracks the subscription's status. |
data |
TData | undefined |
The last data returned by the subscription. |
error |
TRPCClientError | undefined |
The last error thrown by the subscription. |
enable |
() => void |
Enables the store. |
disable |
() => void |
Disables the store. |
toggle |
() => void |
Toggles the store's enabled state. |
reset |
() => void |
Resets the store. |
subscribe |
(input, opts?) => () => void |
Subscribes to the last subscription. Returns a cancellation function. |
Contributions are welcome! Please open an issue or submit a pull request for any features, bug fixes, or improvements.