Server Components and data
In the App Router a page is an async function that awaits the database.
In the App Router a page is an async function that awaits the database. In Dusk the page component only renders;
the data comes from the route's loader.
jsx
// app/posts/[id]/page.tsx (Next.js)
export default async function PostPage({ params }) {
const { id } = await params;
const [post, comments] = await Promise.all([db.post(id), db.comments(id)]);
if (!post) notFound();
return <article><h1>{post.title}</h1><Comments items={comments} /></article>;
}
The database access moves into server functions. They are plain async code on the server; in the browser build the
file keeps only a stub that sends the call over HTTP. The Map and the array stand in for your database.
src/server/blog.tsts
import { getRequestEvent, serverFn } from "@shadow-js/dusk/server";
import { schema } from "../schema";
export interface Post {
id: string;
title: string;
body: string;
}
const posts: readonly Post[] = [
{ id: "1", title: "Hello", body: "The first post." },
{ id: "2", title: "Routing", body: "Nested routes with loaders." },
];
const comments = new Map<string, string[]>([["1", ["First!"]]]);
const postId = schema((value) => {
if (typeof value === "object" && value !== null && "id" in value && typeof value.id === "string") {
return { id: value.id };
}
throw new Error("id must be a string");
});
const newComment = schema((value) => {
if (
typeof value === "object" &&
value !== null &&
"postId" in value &&
typeof value.postId === "string" &&
"text" in value &&
typeof value.text === "string" &&
value.text.trim() !== "" &&
value.text.length <= 280
) {
return { postId: value.postId, text: value.text.trim() };
}
throw new Error("a comment is 1 to 280 characters");
});
export const listPosts = serverFn({ method: "GET" }, async (): Promise<Post[]> => [...posts]);
export const getPost = serverFn(
{ method: "GET", input: postId },
async ({ id }): Promise<Post | undefined> => posts.find((post) => post.id === id),
);
export const listComments = serverFn(
{ method: "GET", input: postId },
async ({ id }): Promise<string[]> => comments.get(id) ?? [],
);
/** The "Server Action": changes data, so it is a POST (the default). */
export const addComment = serverFn(
{ input: newComment, form: true },
async ({ postId, text }): Promise<number> => {
const list = comments.get(postId) ?? [];
list.push(text);
comments.set(postId, list);
return list.length;
},
);
/** Who is signed in, from the request's cookie. Cookies are read on the server only. */
export const viewer = serverFn({ method: "GET" }, async (): Promise<string | null> => {
return getRequestEvent().cookies.get("viewer") ?? null;
});
The loader runs both calls in parallel, like the Promise.all above.
src/routes/posts/[id].tsxtsx
import { useStore } from "@shadow-js/core";
import { type LoaderArgs, notFound as signalNotFound, useLoaderData, useRouter } from "@shadow-js/router";
import type { HeadTag, JSX } from "@shadow-js/web";
import { For } from "@shadow-js/web";
import { addComment, getPost, listComments, type Post } from "../../server/blog";
interface PostData {
post: Post;
comments: string[];
}
export const loader = async ({ params }: LoaderArgs<"/posts/:id", unknown>): Promise<PostData> => {
const [post, comments] = await Promise.all([
getPost({ id: params.id }),
listComments({ id: params.id }),
]);
if (post === undefined) throw signalNotFound();
return { post, comments };
};
export const head = ({ loaderData }: { loaderData: PostData | undefined }): HeadTag[] => [
{ tag: "title", children: loaderData === undefined ? "Post" : `${loaderData.post.title} | Blog` },
{ tag: "meta", props: { name: "description", content: loaderData?.post.body } },
];
export const notFound = (): JSX.Element => <p class="missing">No such post</p>;
function CommentForm(props: { postId: string }): JSX.Element {
const router = useRouter();
const [text, setText] = useStore("");
const [problem, setProblem] = useStore("");
const send = async (): Promise<void> => {
try {
await addComment({ postId: props.postId, text: text() });
setText("");
setProblem("");
await router.invalidate();
} catch (error) {
setProblem(error instanceof Error ? error.message : "failed");
}
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<input value={text()} onInput={(event) => setText(event.currentTarget.value)} />
<button type="submit">Comment</button>
<p class="problem">{problem()}</p>
</form>
);
}
export default function PostPage(): JSX.Element {
const data = useLoaderData<PostData>();
return (
<article>
<h1>{data()?.post.title}</h1>
<p>{data()?.post.body}</p>
<ul>
<For each={data()?.comments ?? []}>{(comment) => <li>{comment}</li>}</For>
</ul>
<CommentForm postId={data()?.post.id ?? ""} />
</article>
);
}