Client Server Actions
Let's go down a more client component driven approach, to submit the new post form. This will allow us to take control by running logic on the client, before sending the data to the server.
"use client"
import { useState } from "react"
import { twMerge } from "tailwind-merge"
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length < 3
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
// We'll post the data soon
}
return (
<main className="text-center mt-10">
<form
onSubmit={handleSubmit}
className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4"
>
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="text-neutral-500">Characters: {content.length}</div>
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
buttonDisabled && "opacity-50"
)}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
</main>
)
}
"use client"
import { useState } from "react"
import { twMerge } from "tailwind-merge"
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length < 3
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
// We'll post the data soon
}
return (
<main className="text-center mt-10">
<form
onSubmit={handleSubmit}
className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4"
>
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="text-neutral-500">Characters: {content.length}</div>
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
buttonDisabled && "opacity-50"
)}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
</main>
)
}
"use client"
import { useState } from "react"
import { twMerge } from "tailwind-merge"
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length < 3
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
// We'll post the data soon
}
return (
<main className="text-center mt-10">
<form
onSubmit={handleSubmit}
className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4"
>
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="text-neutral-500">Characters: {content.length}</div>
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
buttonDisabled && "opacity-50"
)}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
</main>
)
}
"use client"
import { useState } from "react"
import { twMerge } from "tailwind-merge"
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length < 3
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
// We'll post the data soon
}
return (
<main className="text-center mt-10">
<form
onSubmit={handleSubmit}
className="border border-neutral-500 rounded-lg px-6 py-4 flex flex-col gap-4"
>
<label className="w-full">
<textarea
className="bg-transparent flex-1 border-none outline-none w-full"
name="content"
placeholder="Post a thing..."
required
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<div className="text-neutral-500">Characters: {content.length}</div>
<button
type="submit"
className={twMerge(
"border rounded-xl px-4 py-2 disabled",
buttonDisabled && "opacity-50"
)}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
</main>
)
}
This "client" version of the create post page has more room for a better user experience. Right now there is an arbitraty requirement that a post must be at least 3 characters long, but hopefully you see the potential for more complex validation and a better user experience.
Let's break down what's going on in this big code block.
useState
import { useState } from "react" // 1
export default function CreatePost() {
const [content, setContent] = useState("") // 2
return (
<form>
<textarea
name="content"
required
value={content} // 3
onChange={(e) => setContent(e.target.value)} // 4
/>
<div className="text-neutral-500">
Characters: {content.length} {/* 5 */}
</div>
</form>
)
}
import { useState } from "react" // 1
export default function CreatePost() {
const [content, setContent] = useState("") // 2
return (
<form>
<textarea
name="content"
required
value={content} // 3
onChange={(e) => setContent(e.target.value)} // 4
/>
<div className="text-neutral-500">
Characters: {content.length} {/* 5 */}
</div>
</form>
)
}
import { useState } from "react" // 1
export default function CreatePost() {
const [content, setContent] = useState("") // 2
return (
<form>
<textarea
name="content"
required
value={content} // 3
onChange={(e) => setContent(e.target.value)} // 4
/>
<div className="text-neutral-500">
Characters: {content.length} {/* 5 */}
</div>
</form>
)
}
import { useState } from "react" // 1
export default function CreatePost() {
const [content, setContent] = useState("") // 2
return (
<form>
<textarea
name="content"
required
value={content} // 3
onChange={(e) => setContent(e.target.value)} // 4
/>
<div className="text-neutral-500">
Characters: {content.length} {/* 5 */}
</div>
</form>
)
}
- Import
useState
- Create a state for the text input
- Set the value of the text area to be the value of the state variable
- When the value changes, update the state
- Present the value of the state variable as the character count
buttonDisabled
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length <= 3
return (
<form>
<button
type="submit"
className={buttonDisabled && "opacity-50"}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
)
}
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length <= 3
return (
<form>
<button
type="submit"
className={buttonDisabled && "opacity-50"}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
)
}
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length <= 3
return (
<form>
<button
type="submit"
className={buttonDisabled && "opacity-50"}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
)
}
export default function CreatePost() {
const [content, setContent] = useState("")
const buttonDisabled = content.length <= 3
return (
<form>
<button
type="submit"
className={buttonDisabled && "opacity-50"}
disabled={buttonDisabled}
aria-disabled={buttonDisabled}
>
Post
</button>
</form>
)
}
- Create a
buttonDisabled
variable. This is plain variable, not a state variable, but it's based on the value of the state variablecontent
. - Only disable the button when that variable is true
handleSubmit
export default function CreatePost() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
}
return (
<form onSubmit={handleSubmit}>
</form>
)
}
export default function CreatePost() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
}
return (
<form onSubmit={handleSubmit}>
</form>
)
}
export default function CreatePost() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
}
return (
<form onSubmit={handleSubmit}>
</form>
)
}
export default function CreatePost() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(content)
}
return (
<form onSubmit={handleSubmit}>
</form>
)
}
- Create a
handleSubmit
function that just logs the content to the console. - Add the
handleSubmit
function to the form'sonSubmit
prop
If you test out this component, obviously no network requests happen and no data is stored in the database. When we submit the form, we get a console log in the browser and that's it. But by adding client side JavaScript, we can create a more unique experience for the user.
Server Action
But this is all worthless without actually being able to save the data to the database. Let's create a server action!
But there's one issue, we can't create a server action in a client component.
This makes sense if you think about it. All code in a client component will run on the client (web browser) so obviously we can't run server-side code there.
As the name implies, this file will contain our server action.
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
import { db } from "@/db"
import { posts as postsTable } from "@/db/schema/posts"
export async function createPost(content: string) {
console.log(content)
if (!content || content.length < 3) {
return { error: "not enough content" }
}
try {
await db.insert(postsTable).values({
content,
userId: "user-1",
})
} catch (error) {
console.error(error)
return { error: "something went wrong" }
}
revalidatePath("/")
redirect(`/`)
}
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
import { db } from "@/db"
import { posts as postsTable } from "@/db/schema/posts"
export async function createPost(content: string) {
console.log(content)
if (!content || content.length < 3) {
return { error: "not enough content" }
}
try {
await db.insert(postsTable).values({
content,
userId: "user-1",
})
} catch (error) {
console.error(error)
return { error: "something went wrong" }
}
revalidatePath("/")
redirect(`/`)
}
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
import { db } from "@/db"
import { posts as postsTable } from "@/db/schema/posts"
export async function createPost(content: string) {
console.log(content)
if (!content || content.length < 3) {
return { error: "not enough content" }
}
try {
await db.insert(postsTable).values({
content,
userId: "user-1",
})
} catch (error) {
console.error(error)
return { error: "something went wrong" }
}
revalidatePath("/")
redirect(`/`)
}
"use server"
import { redirect } from "next/navigation"
import { revalidatePath } from "next/cache"
import { db } from "@/db"
import { posts as postsTable } from "@/db/schema/posts"
export async function createPost(content: string) {
console.log(content)
if (!content || content.length < 3) {
return { error: "not enough content" }
}
try {
await db.insert(postsTable).values({
content,
userId: "user-1",
})
} catch (error) {
console.error(error)
return { error: "something went wrong" }
}
revalidatePath("/")
redirect(`/`)
}
"use server" // 1
// 2
export async function createPost(content: string) {
// 3
if (!content || content.length < 3) {
return { error: "not enough content" }
}
// 4
try {
await db.insert(postsTable).values(/* ... */)
} catch (error) {
return { error: "something went wrong" }
}
// 5
revalidatePath("/")
redirect(`/`)
}
"use server" // 1
// 2
export async function createPost(content: string) {
// 3
if (!content || content.length < 3) {
return { error: "not enough content" }
}
// 4
try {
await db.insert(postsTable).values(/* ... */)
} catch (error) {
return { error: "something went wrong" }
}
// 5
revalidatePath("/")
redirect(`/`)
}
"use server" // 1
// 2
export async function createPost(content: string) {
// 3
if (!content || content.length < 3) {
return { error: "not enough content" }
}
// 4
try {
await db.insert(postsTable).values(/* ... */)
} catch (error) {
return { error: "something went wrong" }
}
// 5
revalidatePath("/")
redirect(`/`)
}
"use server" // 1
// 2
export async function createPost(content: string) {
// 3
if (!content || content.length < 3) {
return { error: "not enough content" }
}
// 4
try {
await db.insert(postsTable).values(/* ... */)
} catch (error) {
return { error: "something went wrong" }
}
// 5
revalidatePath("/")
redirect(`/`)
}
- Adding "use server" ensures that all code within this file will only ever run on the server.
- This function is going to be invoked using client side JavaScript, so we will send the content as an argument instead of
FormData
. - We can do some basic validation here. This is going to be a duplicate of the client side validation.
- Now we can try to insert the data into the database and handle any errors that might occur.
- If everything goes well, we can revalidate the home page and redirect the user back to the home page.
"use client"
import { createPost } from './actions'
export default function CreatePost() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content)
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("")
}
// ...
}
"use client"
import { createPost } from './actions'
export default function CreatePost() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content)
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("")
}
// ...
}
"use client"
import { createPost } from './actions'
export default function CreatePost() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content)
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("")
}
// ...
}
"use client"
import { createPost } from './actions'
export default function CreatePost() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content)
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("")
}
// ...
}
import { createPost } from './actions' // 1
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content) // 2
// 3
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("") // 4
}
import { createPost } from './actions' // 1
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content) // 2
// 3
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("") // 4
}
import { createPost } from './actions' // 1
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content) // 2
// 3
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("") // 4
}
import { createPost } from './actions' // 1
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await createPost(content) // 2
// 3
if (result.error) {
console.log(error)
// handle the error
return
}
setContent("") // 4
}
- Import the action into the client component. The code won't run on the client because of the "use server" directive at the top of the file.
- Call the function directly from the client side code. This will make an HTTP request and post the data.
- Handle any server side errors. We'll need more state for that.
- Reset the form after the post is created.
This is similar to the form submission we had before, but now we have a lot more control over the process. One drawback is that this won't work without client side JavaScript enabled, however, this process is really common and makes for a better user experience.
Next Safe Action
Being able to create a server action and invoke it from client side JavaScript is great, but it can be even better.
Server actions don't have any built in plan for:
- Type safety: There is compile time typesafety with TypeScript, but we don't have any runtime type safety.
- Input validation: We have to manually check the content length is at least 3 characters long.
- Server errors: Again, we manually manage errors by sending back some arbitary object with the error information in it.
Lucky for us, there's a library called next-safe-action
that handles all of this and more! It makes using server actions a lot easier and safer.
Zod
"Zod is a TypeScript-first schema declaration and validation library."
Basically Zod does type checking at runtime instead of compile time.
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
// creating a schema for strings
const mySchema = z.string();
// parsing
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError
So you can handle invalid types in your logic.
It also validates more fine grained checks. For example, you can make sure a string is at least 3 characters long and at most 280 characters long.
const mySchema = z.string().min(3).max(280);
const mySchema = z.string().min(3).max(280);
const mySchema = z.string().min(3).max(280);
const mySchema = z.string().min(3).max(280);
Zod integrates into all kinds of other libraries including next-safe-action and drizzle.