[]previous and next page

Server Components and data

In the App Router a page is an async function that awaits the database.

For agentsUpdated

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>
  );
}