Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on transforming your React SPA into a modern SSR application.
In our previous lesson, we successfully converted client-side data fetching into a server-centric model using route loaders and createServerFn. You learned how to load data before a page renders, which is a cornerstone of performant SSR applications.
Today, we'll address the other half of the data lifecycle: writing data back to the server. This lesson focuses on the learning outcome: Transform client-side data mutations into server functions while maintaining React Query's client-side cache integration. We'll take the familiar useMutation hook from React Query and adapt it to the Tanstack Start architecture, ensuring that your UI stays perfectly in sync with your backend after every create, update, or delete operation.
1. Recap: The Anatomy of a Client-Side Mutation
In a typical React SPA, you use React Query's useMutation hook to handle data-modifying operations. The core challenge has always been the same: after the mutation completes, the data you fetched previously (your useQuery data) is now stale. How do you update the UI?
React Query provides several strategies to solve this, ranging from simple refetching to sophisticated optimistic updates.
Posting Data to Server from React - Query Updates from Mutations
To refresh our memory on these core concepts, let's watch this excellent video from Leigh Halliday. It clearly explains what mutations are and introduces the three primary strategies for keeping the UI in sync after a mutation. While this video doesn't use Tanstack Start, the React Query principles are universal and form the foundation for what we'll do next.
Please watch the following segments: Introduction (00:00 - 01:05): A quick overview of what mutations are. Strategy 1: Refetching (10:30 - 12:42): Understand the simplest (but slowest) method: invalidating and refetching. Strategy 2: Updating with Server Response (12:42 - 15:09): Learn how to use the mutation's response to update the cache directly, saving a network request. Strategy 3: Optimistic Updates (15:09 - 22:01): Grasp the most advanced pattern for the best user experience.
As you saw, the core task is to manage the client-side cache. Now, let's see how we adapt this to a server-rendered architecture where the mutation logic itself lives on the server.
2. Moving the Mutation to the Server
Just as we used createServerFn for GET requests in our loaders, we'll use it for mutations. The only difference is that we'll specify a different HTTP method, typically POST, PUT, or DELETE.
This ensures that the logic for interacting with your database or other backend services remains securely on the server, completely inaccessible to the client browser.
Server Functions | TanStack Start React Docs
The official Tanstack Start documentation provides the blueprint for creating server functions. Let's review it to see how methods are specified.
Read the sections 'What are Server Functions?', 'Basic Usage', and 'Parameters & Validation'. Pay close attention to how createServerFn({ method: 'POST' }) is used and how input validation (e.g., with Zod) can be chained to ensure type safety across the network boundary.
Here’s a practical example. Imagine you have a client-side function to add a new item to a todo list:
Before: Client-side logic
// A function called by useMutation in your component
async function addTodoOnClient(newItem: { text: string }) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newItem),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
return response.json();
}
After: Converted to a Server Function
We move this logic into a dedicated file (e.g., app/lib/api.ts) and wrap it with createServerFn.
// app/lib/api.ts
import { createServerFn } from '@tanstack/start';
import { db } from '~/server/db'; // Assuming a server-only DB client
export const addTodo = createServerFn('POST', async (newItem: { text:string }) => {
// This code now runs ONLY on the server.
// You can safely access your database or use environment variables here.
console.log('Adding new todo on the server:', newItem.text);
const createdTodo = await db.todos.create({ data: newItem });
return createdTodo;
});
3. Executing Server Mutations from the Client
Now that our mutation logic is on the server, how do we call it from our React component? We still use useMutation, but we connect it to our server function using a special helper from Tanstack Start: useServerFn.
This helper acts as the bridge, making the server function callable from the client as if it were a local async function.
Using Server Functions and Tanstack Query
This article, 'Using Server Functions and Tanstack Query', provides the perfect, concise example of this pattern in action. It demonstrates exactly how to wire everything together.
Read the section titled 'Mutations'. Focus on how addPlaylist is defined as a POST server function, and then how useMutation is configured in the component using mutationFn: useServerFn(addPlaylist).
Let's apply this to our addTodo example. In your component, the useMutation hook would be transformed like this:
Before: Calling a client-side fetch
const addTodoMutation = useMutation({
mutationFn: addTodoOnClient, // The client-side fetch function
// ... onSuccess, etc.
});
After: Calling the server function
import { useMutation } from '@tanstack/react-query';
import { useServerFn } from '@tanstack/start';
import { addTodo } from '~/lib/api'; // Our new server function
// ... inside your component
const addTodoMutation = useMutation({
mutationFn: useServerFn(addTodo), // The server function wrapped in useServerFn
// ... onSuccess, etc.
});
// You call it the same way
addTodoMutation.mutate({ text: 'Learn server mutations' });
4. Integrating with React Query's Cache
We've successfully moved the mutation logic to the server. Now we must address the main objective: keeping the client's cache in sync. The strategies are the same ones you reviewed in the video, but the implementation within Tanstack Start has some specific patterns.
Strategy 1: Query Invalidation (Good)
This is the simplest and most common strategy. After the mutation succeeds, you tell React Query to invalidate the query that holds the list of todos. React Query will then automatically refetch the data.
You can do this in the onSuccess callback of a specific mutation:
const queryClient = useQueryClient();
const addTodoMutation = useMutation({
mutationFn: useServerFn(addTodo),
onSuccess: () => {
// Invalidate the query for the todo list
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
A more powerful pattern, especially for larger apps, is to configure this globally. You can set up your QueryClient to automatically invalidate all queries after any mutation settles.
Using Server Functions and Tanstack Query
The 'Using Server Functions and Tanstack Query' article demonstrates this elegant global invalidation pattern.
Re-read the end of the 'Mutations' section. Notice how the QueryClient is instantiated with a mutationCache. This onSettled logic automatically invalidates queries, removing boilerplate from individual useMutation calls.
This global setup in your app/router.tsx is a highly effective way to ensure data consistency across your application with minimal effort.
Strategy 2: Optimistic Updates (Best UX)
For the best user experience, you don't want the user to wait for the refetch to complete. With an optimistic update, you assume the mutation will succeed and update the UI instantly.
The process, as outlined in the video you watched, is:
onMutate: Cancel ongoing queries, get the current cache state, and manually add the new (temporary) item to the cache. Return the old state for potential rollback.onError: If the mutation fails, use the context fromonMutateto roll the cache back to its previous state.onSettled: Invalidate the query to ensure the client eventually syncs with the true server state (e.g., to get the real ID generated by the database).
Here is how you would implement this for our addTodo mutation. Note that this code is more complex, but the resulting user experience is instantaneous.
const queryClient = useQueryClient();
const addTodoMutation = useMutation({
mutationFn: useServerFn(addTodo),
// 1. Called immediately when `mutate` is fired
onMutate: async (newTodo) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot the previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update to the new value
queryClient.setQueryData(['todos'], (old) => [
...old,
{ ...newTodo, id: `temp-${Date.now()}` } // Use a temporary ID
]);
// Return a context object with the snapshotted value
return { previousTodos };
},
// 2. If the mutation fails, use the context returned from onMutate to roll back
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
// 3. Always refetch after error or success to sync with the server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
This pattern perfectly marries the server-centric logic of Tanstack Start with the sophisticated client-side caching capabilities of React Query.
Conclusion
You have now completed the full CRUD data lifecycle in a Tanstack Start application. You can read data on the server with loaders and write data back using server functions for mutations.
Key Takeaways:
- Data mutations (Create, Update, Delete) are implemented using
createServerFnwith methods likePOST. This keeps sensitive logic on the server. - The
useMutationhook from React Query is the client-side entry point for triggering these operations. - The
useServerFnhelper from Tanstack Start is the essential glue that connectsuseMutationto yourcreateServerFn. - You have a choice of cache management strategies, each with a different trade-off between implementation complexity and user experience:
- Query Invalidation: Simple and reliable. Good default choice.
- Optimistic Updates: More complex but provides an instantaneous, superior user experience.
Next Up:
Our application's data flow is now robust, but what about code that fundamentally cannot run on the server? Many applications and third-party libraries rely on browser-specific APIs like window, document, or localStorage. In our next lesson, we will tackle this head-on: Refactor components with client-side dependencies to run conditionally or be isolated using dynamic imports with ssr: false.