DEV Community

Arthur Eberle
Arthur Eberle

Posted on

No More Alpha: The Best Way to Mutate Data in Next.js 13 Without Touching Server Actions?

Are you annoyed by repeating yourself over and over when doing mutations in Next.js 13 app directory? Afraid of using server actions because they are still in alpha? Say no more.

Bart Simpson writing Dont Repeat Yourself

In this post I'll present you a simple React Hook that mimics React Query's useMutation Hook to achieve higher reusability and maintainability.

What is Even a Mutation?

In web development, mutations are typically used to create/update/delete data or perform server side-effects. Think of it like updating a user's profile picture or changing a post's title. It's about making sure our apps can react and update based on user actions. This typically happens through POST, PATCH, PUT or DELETE requests.

Why Not Server Actions?

Well, firstly, at the time of writing this post, server actions are still experimental and in alpha. So by definition, it is in an early stage of development and might not be fully stable or subject to change. Therefore, I highly advise against using server actions in production.

And secondly, even if they would be stable, they are a Next.js and Vercel thing. It could be problematic to migrate from it to another platform. API routes on the other hand are more universal. It's easier to switch between e.g. Vercel's Serverless Functions and Cloudflare Workers (or any other provider).

The wrong way

Does the following code look familiar to you? Does it occur in multiple places of your app?

const [isLoading, setIsLoading] = useState(false);

const handleSubmit = async (formInput: FormInput) => {
  setIsLoading(true);
  try {
    const response = await fetch("/example", {
      method: "POST",
      body: JSON.stringify({ formInput }),
    });
    if (!response.ok) throw new Error("Something went wrong!");
    // Handle success
  } catch (error) {
    // Handle error
  } finally {
    setIsLoading(false);
  }
};

[...]

<Button onClick={() => handleSubmit({ name: "xyz" })}>
  {isLoading ? (
    <>
      <LoadingSpinner />
      <span>Creating...</span>
    </>
  ) : (
    <span>Create</span>
  )}
</Button>
Enter fullscreen mode Exit fullscreen mode

Back in the days of the Pages Directory I was simply using React Query for mutations, which makes updating server state in your web applications a breeze. But when using React's Server Components, most of it's advantages fall short. Data is now typically fetched on the server and passed to client components via props. Also, Next.js now handles the caching for you (with all it's downsides, but that's another topic).

This led to a lot of places in my app's code where i needed to manually handle all the logic of mutations, as you can see above. Not only is this kind of annoying, it also isn't a best practice and violates the DRY Principles.

A better way (the thing you're here for)

What follows is a simple React Hook that offers a native solution for the aforementioned problem:

// lib/hooks/use-mutation.tsx

import { useCallback, useState } from "react";

// Input of the hook
type MutationOptions<Data, Variables, Context = unknown> = {
  mutationFn: (variables: Variables) => Promise<Data>;
  onMutate?: (variables: Variables) => Context;
  onError?: (error: Error) => void;
  onSuccess?: (data: Data, variables: Variables, context: Context) => void;
};

// Output of the hook
type MutationResult<Data, Variables> = {
  mutate: (variables: Variables) => Promise<void>;
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
  data: Data | null;
  error: Error | null;
};

// Actual Hook Implementation
export function useMutation<Data, Variables, Context = unknown>({
  mutationFn,
  onMutate = () => ({}) as Context,
  onError = () => {},
  onSuccess = () => {},
}: MutationOptions<Data, Variables, Context>): MutationResult<Data, Variables> {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isError, setIsError] = useState<boolean>(false);
  const [isSuccess, setIsSuccess] = useState<boolean>(false);
  const [data, setData] = useState<Data | null>(null);
  const [error, setError] = useState<Error | null>(null);

  // Callback used here to avoid unnecessary re-renders,
  // when passing the mutate function to child components
  const mutate = useCallback(
    async (variables: Variables) => {
      setIsLoading(true);
      setIsError(false);
      setIsSuccess(false);
      setData(null);
      setError(null);

      let context: Context;
      try {
        // Return the context to be used in onSuccess
        context = onMutate(variables); 
        const result = await mutationFn(variables);
        setData(result);
        setIsSuccess(true);
        onSuccess(result, variables, context);
      } catch (error) {
        setError(error as Error);
        setIsError(true);
        onError(error as Error);
      } finally {
        setIsLoading(false);
      }
    },
    [mutationFn, onMutate, onError, onSuccess]
  );

  return {
    mutate,
    isLoading,
    isError,
    isSuccess,
    data,
    error,
  };
}
Enter fullscreen mode Exit fullscreen mode

And here is how you can use it inside your components:

type Data = { id: string };

const mutation = useMutation<Data, FormInput>({
  mutationFn: async (formInput) => {
    const response = await fetch("/example", {
      method: "POST",
      body: JSON.stringify({ formInput }),
    });
    if (!response.ok) throw new Error("Something went wrong!");
    const data = await response.json();
    return data;
  },
  onSuccess(data) {
    // Handle success with data.id ("data" is of type "Data")
  },
  onError(error) {
    // Handle error
  },
});

[...]

<div>
  {mutation.isLoading ? (
    "Adding todo..."
  ) : (
    <>
      {mutation.isError ? (
        <div>An error occurred: {mutation.error?.message}</div>
      ) : null}

      {mutation.isSuccess ? <div>Todo added!</div> : null}

      <button onClick={() => mutation.mutate({ name: "xyz" })}>
        Create Todo
      </button>
    </>
  )}
</div>
Enter fullscreen mode Exit fullscreen mode

Advantages

Native

Instead of relying on bulky external packages, this solution is lightweight and built directly into your app. This means better performance and fewer potential security risks.

Reusable

By using this hook, you can cut down on repetitive mutation code in your app. This means less repeated logic and easier updates.

Decoupling

This hook separates mutation logic from your component. It makes the app easier to tweak because changing one part doesn't mess up another.

Room to improve

The actual useMutation Hook from React Query is much more powerful. It provides a solution for optimistic updates for example which is not included here. There is also an onSettled callback function that I simply did not need and an async mutate function called mutateAsync.

Conclusion

Embracing new technologies and methodologies often comes with its fair share of challenges. The solution I presented here - a simple React Hook - provides an efficient way to manage mutations without relying on external packages. It promotes clean, DRY code, and separates concerns to ensure better maintainability. While it may not encompass all the features offered by more comprehensive solutions like React Query's useMutation, it offers a solid foundation upon which you can build and customize according to your needs.

This is my first blog post ever, so I am open to any feedback :)

Top comments (0)