Revolutionize Your Backend: Mastering Server Actions with Next.js and React

@avindrafernando

Decisions???

  • Bundling
  • Caching
  • Routing
  • Image Optimization
  • Data Fetching
  • Bundle Splitting
  • Rendering
  • Developer Experience

Who Am I?

@avindrafernando

@avindra1

avindrafernando

taprobaneconsulting.tech

Delivering a fast web experience can be challenging

Rendering

Client Side Rendering (CSR)

useEffect

const Posts = () => {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const fetchPosts = async () => {
      const result = await PostsApi.getPosts();

      setPosts(result.slice(0, 75));
    };
    fetchPosts();
  }, []);
  ...

React Server Components

PHP
All Over
Again?

Pre Rendering in Next.js

Static Rendering or SSG

Dynamic Rendering or SSR

So, what
is
'use client'?

So, what
are
server actions?

Server Actions are async functions that run on the server.

Server Actions are async functions that only run on the server.

Why? 🤔

Server Actions can

  • Write directly to a database
  • Execute business logic
  • Call external APIs

async function myServerAction() {
  'use server';
 
  // do something
}

Wait, what
is
'use server'?

'use client'

'use server'

'server only'

async function myServerAction() {
  'use server';
 
  // do something
}
'use server'

async function myServerAction() {

  // do something
}

async function anotherServerAction() {

  // do something
}

async function oneMoreServerAction() {
  
  // do something
}
export function MyFormComponent() {
  function handleFormAction(
    formData: FormData
  ) {
    'use server';
 
    const name = formData.get('name');
    // do something
  }
 
  return (
    <form action={handleFormAction}>
      <input type={'name'} />
 
      <button type="submit">Save</button>
    </form>
  );
}

action attribute on a form 

export function Form() {
  async function handleSubmit() {
    'use server';
    // ...
  }
 
  return (
    <form>
      <input type="text" name="name" />
      <button formAction={handleSubmit}>Submit</button>
    </form>
  );
}

formAction attribute on a button

'use client';
 
import { useTransition } from 'react';
import { saveData } from '../actions';
 
function ClientComponent({ id }) {
  let [isPending, startTransition] = useTransition();
 
  return (
    <button onClick={() => startTransition(() => saveData(id))}>
      Save
    </button>
  );
}

startTransition function of useTransition hook

Let's get started

Let's
Define a
DB Schema

const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: varchar('name', { length: 50 }),
  username: varchar('username', { length: 50 }),
  email: varchar('email', { length: 50 })
});

Users Schema

export async function createUser(user: SelectUserWithoutId) {
  await db.insert(users).values(user);
}

export async function updateUserById(user: SelectUser) {
  await db
    .update(users)
    .set({ name: user.name, email: user.email, username: user.username })
    .where(eq(users.id, user.id));
}

export async function deleteUserById(id: string) {
  await db.delete(users).where(eq(users.id, id));
}

Users CRUD Functions

Now
the Server Actions

export async function addUser(formData: FormData) {
  const newUser: SelectUserWithoutId = {
    name: String(formData.get('name')) ?? '',
    email: String(formData.get('email')) ?? '',
    username: String(formData.get('username')) ?? ''
  };

  await createUser(newUser);
}

actions.ts addUser

export async function updateUser(user: SelectUser, formData: FormData) {
  const updatedUser: SelectUser = {
    id: user.id,
    name: String(formData.get('name')) ?? user.name,
    email: String(formData.get('email')) ?? user.email,
    username: String(formData.get('username')) ?? user.username
  };

  await updateUserById(updatedUser);
}

actions.ts updateUser

export async function deleteUser(userId: string) {
  await deleteUserById(userId);
}

actions.ts deleteUser

Dialog for Add/Edit User

export function UserDialog({ user }: { user?: SelectUser }) {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button className="w-full" size="sm" variant="outline">
          {user ? 'Edit' : 'Add'}
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
		...
        <form action={user ? updateUser.bind(null, user) : addUser}>
...

Server Actions using form action

<form action={user ? updateUser.bind(null, user) : addUser}>
<form>
  ...
  <DialogFooter>
    <Button
      type="submit"
      onClick={() => {
          setOpen(false);
      }}
      formAction={user ? updateUser.bind(null, user) : addUser}
    >
      Save changes
    </Button>
  </DialogFooter>
</form>

Server Actions using button formAction

function UserRow({ user }: { user: SelectUser }) {
  const userId = user.id;
  const deleteUserWithId = deleteUser.bind(null, userId);
  const [isPending, startTransition] = useTransition();

  return (
    <TableRow>
	  ...
      <TableCell>
        <Button
          className="w-full"
          size="sm"
          variant="outline"
          onClick={() => startTransition(() => deleteUserWithId())}
        >
...

Server Actions using useTransition

How do I refresh data after a server action?  🤔

revalidatePath to the rescue

export async function updateUser(user: SelectUser, formData: FormData) {
  const updatedUser: SelectUser = {
    id: user.id,
    name: String(formData.get('name')) ?? user.name,
    email: String(formData.get('email')) ?? user.email,
    username: String(formData.get('username')) ?? user.username
  };

  await updateUserById(updatedUser);
  revalidatePath('/');
}

revalidatePath

Pending states? 🤔

function SubmitButton({ user, setOpen }) {
  const { pending } = useFormStatus();

  return (
    <Button
      type="submit"
      formAction={user ? updateUser.bind(null, user) : addUser}
      disabled={pending}
    >
      {pending && <ButtonSpinner />}
      Save changes
    </Button>
  );
}

Form Status

Error Handling? 🤔

export async function deleteUser(userId: string) {
  try {
    await deleteUserById(userId);
  } catch (e) {
    throw new Error('Failed to delete user');
  }

  revalidatePath('/');
}

Error Handling

Server Side Validation? 🤔

export type FieldError = {
  name?: string[];
  email?: string[];
  username?: string[];
};

export type FormState = {
  message: string;
  status: 'idle' | 'pending' | 'error' | 'success';
  errors?: FieldError;
};

const userSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Please enter a valid email address'),
  username: z.string().min(3, 'Username must be at least 3 characters')
});

Server Side Validation with Zod

export async function updateUser(
  user: SelectUser,
  previousState: FormState,
  formData: FormData
): Promise<FormState> {
  const validatedFields = userSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    username: formData.get('username')
  });

  if (!validatedFields.success) {
    return {
      status: 'error',
      message: 'Validation failed',
      errors: validatedFields.error.flatten().fieldErrors
    };
  }
...

actions.ts updateUser

const [state, formAction] = useFormState(
  user ? updateUser.bind(null, user) : addUser,
  { message: '', status: 'idle' }
);

useFormState (a.k.a. useActionState)

{state && state.status === 'error' && state.errors && (
  <div
    id="email-error"
    aria-live="polite"
    className="text-sm text-red-500 text-right grid items-center"
  >
    {state.errors?.name?.map((error: string) => (
      <p key={error}>{error}</p>
    ))}
	{state.errors?.email?.map((error: string) => (
      <p key={error}>{error}</p>
	))}
	{state.errors?.username?.map((error: string) => (
      <p key={error}>{error}</p>
	))}
  </div>
)}

Zod errors on form

So, how
do I
start?

Migration

Stats please...

Stay Hungry. Stay Foolish.

— Steve Jobs

@avindrafernando

Taprobane Consulting, LLC

@avindrafernando