Ghost in the shell (1995)

Safe(r) usage of React Server Actions with RHF and Tanstack Query

MrManafon
Homullus
Published in
13 min readJul 14, 2024

--

React server actions are amazing. They are, however, often used in an unsafe manner, while most online discussions consider typesafety to be the only type of safety — and we can fix that.

We are in a midst of a shift in React. With the introduction of server logic into an ecosystem where a majority of developers are specialized for frontend, and with Next.JS switching from pure FE to a mostly BE framework, there are major learning opportunities. The concepts in this blogpost are considered common sense in other backend frameworks with more mature communities, and for a good reason.

In this post I share some of my thoughts and simple ways of tapping into that sweet developer experience, through applying backend concepts that more mature backend frameworks have mastered by now. I’ve made a tiny demo repository that showcases a working example:

The whole point of this post is that you don’t need abstractions and mastercrafted packages to work with server actions. They are similar to any other backend API, and if you just apply some commonplace principles to them, they are as safe they can get. In this post, we’ll write a very simple reusable wrapper to minimize repetition and formalize usage of these principles.

Server actions are, basically, just POST APIs in disguise. In Next.JS for example, the CI just silently wraps them into a POST API variant of the current URL. The idea is to:

  1. Remove the additional steps of writing boilerplate, deciding on naming and similar.
  2. Remove the additional steps of writing a two sided interface contract.
  3. Achieve compile-time typesafety in the IDE.

They improved the way I use Next.JS tremendously. They do all of these perfectly well. But they are still, absolutely, just an API transport. Seriously, look at how elegant the concept is:

// createUserAction.ts

"use server";

export function createUserAction(input: { name: string; email: string }) {
const { name, email } = input;
return await pretendToSaveUserToDatabase(name, email);
}

// Form.tsx

"use client";

export default function Form() {
return <form onSubmit={createUserAction}>...</form>
}

And not only that — while the official docs almost always talk about server actions usage in the context of frontend forms — they can be used for any data mutation.

export default function AddToCartButton({sku, quantity}: {sku: string, quantity: number}) {
return <div onClick={() => addToCartAction({sku, quantity})}>Form</div>;
}

Backend safety in 5 easy steps

Like any other transport (fx. tRPC) the method itself doesn’t change the fact that the server still has to go through the exact same steps (in order):

  1. Authorization and Permissions check.
  2. Input Validation, Sanitization and Transformation.
  3. Invoking a handler that performs the actual work.
  4. Transforming the response into a public DTO, in a step known as API-minimization.
  5. Responding with data or errors in a standardized envelope.

These steps are universal. Whichever framework or language you use, the steps are exactly the same, and in the same order. Sometimes, the framework will hide some of the boilerplate from you, or automate whole steps away — but they still do happen.

I see a lot of confusion regarding this in the React world. Even in unexpected places that should understand this perfectly well, such as T3 tRPC GitHub discussions, where both actions and tRPC are often not seen as just transport mechanisms, and instead confused for replacing these steps with magic.

☝️ I can’t share this article enough. If you actually listen to Vercel, you’ll quickly see that they know this very well. They may use different terms for it in the official blogpost, but the steps are always there.

Katekyo Hitman Reborn! (2004) — All backend frameworks go through basically the same steps. Sure, sometimes its hidden, but the steps still happen.

In most frameworks, you’ll see a combination of middleware and facade patterns, where the whole API is a middleware/plug (which enforces things like routing table or authorization and can abstract away Step 1), meanwhile each individual endpoint enters a facade that ensures steps 2, 3 and 5 are performed. This way the only thing your actual code needs to worry about is “doing work and step 4”.

I’d love to shout out an amazing lowkey JS backend framework that has mastered this ideaNestJS. We used to use NestJS back when i worked at Weld, and one of the many great features was that they deeply understood this concept and the fact that the transport layer can be abstracted away. Look at this example, your code has no idea if the request is coming from via HTTP, RabbitMQ, gRPC, tRPC, socket, a Redis row or a messenger pidgeon or all of them in parallel — the transport(s) is configured elsewhere, and is Liskov-friendly:

@OnEvent('order.created')
handleOrderCreatedEvent(payload: OrderCreatedEvent) {
// handle and process "OrderCreatedEvent" event
}

☝️ Your business logic is independent and simpler, while you are also free to separately pick a transport that best fits your technical constraints.

Step 4, potentially the most important one, is usually left either to the user-space handler code or automated through framework conventions (for example Symfony Data Transformers). You must never simply return stuff from the backend, you must always explicitly transform it into a DTO (data transfer object), thus explicitly ensuring that only certain fields are available to the outside world. Key-word → explicit.

We’ve seen the React team try to optimise Step 4 in ways that I really really dislike. Latest attempt is taint which labels parts of server entities as private (see Theo’s video). Look, this has been tried for decades now in other languages, and it just ends up being a leaky and implicit abstraction that is hard to debug and a safety hazard. Sometimes manual labor is a better choice, and explicit transformation is the way to go.

Anyway, seriously, that is the bird’s eye view of the entire dance.

Next.JS Safe Action

Some of these steps are performed by Next directly, while for most others the community has come up with a great package next-safe-action. Yes, you can build all of it yourself, but it has borrowed a lot of inspo from how T3 did it in their tRPC implementation. You also get a really nice middleware for your new action layer. It is undoubtedly safe.

The problem I see with it is that it feels like a bit too much. It takes us almost all the way back into writing a bunch of boilerplate, at which point — we might as well just use the API layer like normal people.

Corto Maltese, thinking about server action typesafety.

It really isn’t that hard nor complex to make actions safer. This is basically the same level of “safety” as it follows all of the above-mentioned principles, but significantly easier to debug, simpler to read, and doesn’t require additional dependencies:

export async function createUserAction(input: CreateUserDTO) {
// Validate and Sanitize the input.
const { name, email } = CreateUserDTO.parse(input);

try {
// Process the validated data (e.g., save to database)
const user = await saveUser(name, email);

// Transform the output into a DTO.
const responseDto = { name: user.name, email: user.email };

// Respond with a success envelope.
return { data: responseDto };
} catch (e: unknown) {
console.error(e);
throw Error("Internal Server Error"); // Mask internal errors.
}
}

You can also take a look at a more complex demo I made. In it, I put this try block into a 10-line reusable “wrapper” in order to minimize repetition, and used a couple of commonplace libraries like zod and React Query that you already have installed anyway. I use form validation as an example, but in reality it could be any sort of async mutation or data query.

Using whats out there — Zod

Easy choice. Sure, the type spec of the server action gives us compile-time typesafety, but on the backend we want to assure at runtime, that the incoming data is correct.

We must never trust the incoming data, even when it comes from our own systems.

You just draw a schema and pass the data to it as the first thing you do in your server actions, and viola. It throws if the data isn’t correct, and asserts the type.

const { postId, languageCode } = z.object({
postId: z.string().min(1),
languageCode: z.string().min(2),
}).parse(input);

People often forget that zod is not a way to describe types — it is a schema validation library, that coincidentally also describes types.

Pro tip: I didn’t know this until recently but you can export a variable and a type under the same name.

// A real Zod schema validator object.
export const CreateUserDTO = z.object({
name: z.string().min(1),
email: z.string().email().min(1),
age: z.coerce.number().min(18),
});

// It's resulting type: { name: string; email: string; age: number }
export type CreateUserDTO = z.infer<typeof CreateUserDTO>;

Most backend languages have some sort of powerful schema validation either built-in or maintained, be it runtime typesafety of PHP and Java, or Form DTO validation in Symfony, or simply pattern matching in Elixir. Just look at this example from the official Laravel docs:

public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|unique:posts|max:255',
'body' => 'required',
]);

If that looks familiar, it is because something like it is present in literally any and every framework. Here is how we do it in Next.JS:

// with tRPC
getPost: publicProcedure
.input(z.object({ slug: z.string().max(255) }))
.query(async ({ input }) => {

// with API routes
export async function GET(request: NextRequest) {
const urlSearchParams = new URLSearchParams(request.nextUrl.search);
const jsonData = Object.fromEntries(urlSearchParams.entries());
const { lineItems } = AddItemsZod.parse(jsonData);

// And here is is in a server action:
export async function addItemsToCart(input: AddItemsZod) {
const { lineItems } = AddItemsZod.parse(input);

Using whats out there — Tanstack Query

We can easily and effortlessly use React Query with server actions. Actions are async functions, and RHQ accepts async functions. Thats it, I don’t have much more to say. You all know why RHQ is a good choice for query state management as well as mutation state management. It became a de-facto standard nowadays and newer libraries are all modeling their interfaces based on what RHQ did.

const { mutate, isPending } = useMutation({
mutationFn: submitFormAction,
});

You get all the benefits of Server Actions, with all the added utility of useMutation.

Hot Take: The same actually goes for queries — yes, POST can be (and is commonly) used for querying data, contrary to what you’ll hear on Twitter. A good example of this are “get-or-create” queries, for example a cart on an e-commerce website. You just have to put your backend developer hat on, and be aware of the tradeoffs — a lack of caching, visibility and status codes — but in turn you get flexibility, data complexity, payload size and structure, body transport anonymity.

const { data, isLoading } = useQuery({
queryKey: ["getCustomer"],
queryFn: () => getCustomerAction(),
});

A tiny tricky caveat to remember is that you should always return a standardized envelope. It is easy to think you can return a void or an undefined but RHQ can’t handle null or undefined return values. This is no different from any other API you would make — I always try to respond with an object like:

{ success: boolean, timestamp: number, data: Record<string, unknown> }

When it comes to server actions return envelopes, I’ve decided that the data property is sufficient, especially since we get the other metadata automatically from RHQ.

Using whats out there — React Hook Form

Another nice utility we can integrate with is RHF or Tanstack Form which make collecting user input a nice experience, especially through integrations with shadcn/ui and Radix.

import { zodResolver } from "@hookform/resolvers/zod";

const form = useForm<CreateUserDTO>({
resolver: zodResolver(CreateUserDTO),
});

There is a Zod resolver for RHF, which out-of-the-box allows for integration between the two on the client. You just pass it a Zod schema, and it knows what to do and how to attach errors to each specific individual field and keep the form state up to date with the rendered fields.

Let’s combine it with React Query and a server action on submission:

export default function UserForm() {
const { mutate } = useMutation({ mutationFn: submitFormAction });
const form = useForm<CreateUserDTO>({ resolver: zodResolver(CreateUserDTO) });

return (
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => mutate(data))}>
<Input name="name" />
<Input name="email" />
<Input name="age" type="number" />
<Button type="submit">Submit</Button>
</form>
</Form>
);
}

Obviously you can expand on this by creating skeletons and loading animations based on the useMutate's isPending.

Don’t forget server validation!

We should validate the data and display errors on the client, as that creates a very fast and nice experience for the user. We should, however, also validate the data again on the server. Luckily since Zod schemas are stored in a variable, they can be exported and simply used in both places.

export async function createUserAction(data: CreateUserDTO) {
const { name, email } = CreateUserDTO.parse(data);
const user = await saveUser(name, email);
return { name: user.name, email: user.email };
}

So, even if an attacker (or a bug, or deployment drift) allows submission on the Frontend, our backend will notice the error and throw an error. This throw will by default result in a 500 return code, and the client won’t really know what happened — other than the request failed.

Putting it all together

To summarize we can wrap all of this stuff into 2 simple functions, each 10 lines long. First to demysitfy the wrapper — here is exactly what we do in the the ServerAction() wrapper, nothing fancy — you could easily do this yourself in each action, or just use the wrapper:


export function ServerAction<
InputZod extends z.ZodObject<z.ZodRawShape>,
Output
>(validator: InputZod, action: (input: z.infer<InputZod>) => Promise<Output>) {
return async (input: z.infer<InputZod>) => {
// Validate and Sanitize the input.
const validatedInput = validator.parse(input);

try {
// Process the validated data (e.g., save to database)
const responseDto = await action(validatedInput);
// Respond with a success envelope.
return { data: responseDto };
} catch (e: unknown) {
console.error(e);
throw Error("Internal Server Error"); // Mask internal errors.
}
};
}

You can take a look at the attached repository, I’ll just show the usage here. Let’s look at how we declare a server Action:

// createUserAction.ts

"use server";

export const createUserAction = ServerAction(CreateUserDTO, async ({ name, email }) => {
const user = await pretendToSaveUser(name, email);
return { name: user.name, email: user.email };
});

By just wrapping a callback in our wrapper, we’ve ensured that input data is validated, and output (both success, error and validation-error) is returned in a standardized envelope. The only thing we as a user need to do is to give it a Zod Schema, and give it a handler callback as we normally would. No changes whatsoever.

Then, lets briefly turn our focus to the client form. We use RHF and RHQ to manage the form state and the mutation itself:

// Form.tsx

"use client";

export default function Form() {
const form = useForm<CreateUserDTO>({ resolver: zodResolver(CreateUserDTO) });

const { mutate } = useMutation({ mutationFn: createUserAction });

return (
<form onSubmit={form.handleSubmit((data) => mutate(data))}>

We ensure that the form is validated in the frontend, but also support one-sided invalidation from the backend, if an error is returned. Thanks to RHQ and RHF we have a ton of power to make more advanced decisions in the React state, in a way that feels totally native to the React world.

Re-render client form with server form errors

How nice would it be to be able to hit the server validation, and automatically update the FE form with errors, in the exact same way we already do on the FE? It is quite common in Java, Elixir, PHP or Python to send validation errors back to the client, so that the client can display them.

In this example i disabled client validation entirely. The error comes from the same Zod schema, used inside the server action.

Obviously, this only works in cases where your form and action use the same Zod schema. This is why we introduce a separate `ServerFormAction` wrapper.

The wrapper basically does the exact same thing as ServerAction it just taps into Zod’s safeParse() which doesn’t throw and instead gives us a pretty error structure that we can ship to the frontend.

// Validate and Sanitize the input.
const { error, data: validatedInput } = validator.safeParse(input);
if (error) return { data: undefined, validationError: error.flatten() };
// Invoke handler...

It isn’t that hard. We just have to agree that an action will return a standardized envelope:

{ data: Record<string, unknown>, validationError: undefined }
{ data: undefined, validationError: FlattenedValidationErrors }

In case of a validation error, the action will return a flattened list of errors to the FE, which we’ll attach to the form using form.setError() which i wrapped into a simple utility function that loops over the errors array and attaches each one:

const { mutate } = useMutation({
mutationFn: submitFormAction,
onSuccess: (payload) => {
if (payload.validationError) return applyServerErrors(form, payload.validationError);
// Handle actual success.
console.log("Success!", payload.data);
},
});

This is really nice — we aren’t pretending to invalidate fields through some jQuery spaghetti (like we used to do back in the day), we are genuinely talking to the React Form object and using the RHF-native way to set the error state, with all the safeguards and accessibility that comes with it — for example, the invalid field gets labeled as invalid in the DOM and automatically focused.

I understand this all feels simplistic, and this is because it is. The goal of this post is to showcase that you don’t need complex wrappers and solutions in order to make Server Actions safe. Just keep the 5 steps above on your mind and always be aware exactly when and how they happen.

--

--