Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

server actions / rsc play #5569

Draft
wants to merge 15 commits into
base: 03-08-infer-error
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/.experimental/next-app-dir/package.json
Expand Up @@ -11,6 +11,7 @@
"test-start": "start-server-and-test start 3000 test:e2e"
},
"dependencies": {
"@hookform/error-message": "^2.0.1",
"@hookform/resolvers": "^2.9.11",
"@tanstack/react-query": "^5.25.0",
"@trpc/client": "npm:@trpc/client@next",
Expand All @@ -20,11 +21,12 @@
"@types/node": "^20.10.0",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"next": "^14.0.1",
"next": "^14.1.3",
"next-auth": "^4.22.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.3",
"react-hot-toast": "^2.4.1",
"superjson": "^1.12.4",
"trpc-api": "link:./src/trpc",
"typescript": "^5.4.0",
Expand Down
43 changes: 37 additions & 6 deletions examples/.experimental/next-app-dir/src/app/layout.tsx
@@ -1,11 +1,31 @@
import { auth } from '~/auth';
import Link from 'next/link';
import { Suspense } from 'react';
import { Toaster } from 'react-hot-toast';

export const metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};

export default function RootLayout({
async function LoginLogout(props: { forceLoggedOut?: boolean }) {
const session = props.forceLoggedOut ? null : await auth();

// make it slow a bit
await new Promise((resolve) => setTimeout(resolve, 300));

return (
<Link
href={session ? '/api/auth/signout' : '/api/auth/signin'}
style={{
color: 'hsla(210, 16%, 80%, 1)',
}}
>
{session ? 'Sign out' : 'Sign in'}
</Link>
);
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
Expand All @@ -27,14 +47,24 @@ export default function RootLayout({
gap: '1rem',
}}
>
<Link
href="/"
<nav
style={{
color: 'hsla(210, 16%, 80%, 1)',
display: 'flex',
gap: '1rem',
}}
>
Home
</Link>
<Link
href="/"
style={{
color: 'hsla(210, 16%, 80%, 1)',
}}
>
Home
</Link>
<Suspense fallback={<LoginLogout forceLoggedOut />}>
<LoginLogout />
</Suspense>
</nav>
<div
style={{
padding: '1rem',
Expand All @@ -46,6 +76,7 @@ export default function RootLayout({
{children}
</div>
</main>
<Toaster />
</body>
</html>
);
Expand Down
10 changes: 10 additions & 0 deletions examples/.experimental/next-app-dir/src/app/page.tsx
Expand Up @@ -30,6 +30,16 @@ export default async function Index() {
padding: 0,
}}
>
<li>
<Link
href="/posts"
style={{
color: 'hsla(210, 16%, 80%, 1)',
}}
>
Some new experimental stuff as of Feb 2024
</Link>
</li>
<li>
<Link
href="/rsc"
Expand Down
@@ -0,0 +1,164 @@
'use client';

import { ErrorMessage } from '@hookform/error-message';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useMemo, useState } from 'react';
import { useFormState } from 'react-dom';
import type { UseFormProps } from 'react-hook-form';
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import toast from 'react-hot-toast';
import type { z } from 'zod';
import { formDataAction } from './_data';
import { addPostSchema } from './_data.schema';

/**
* Reusable hook for zod + react-hook-form
*/
function useZodForm<TSchema extends z.ZodType>(
props: Omit<UseFormProps<TSchema['_input']>, 'resolver'> & {
schema: TSchema;
},
) {
const form = useForm<TSchema['_input']>({
...props,
resolver: zodResolver(props.schema, undefined),
});

return form;
}

export function SubmitButton(
props: Omit<JSX.IntrinsicElements['button'], 'type'>,
) {
const ctx = useFormContext();

return (
<button
type="submit"
{...props}
disabled={ctx.formState.isSubmitting || props.disabled}
/>
);
}
export function AddPostForm_RHF() {
const [serverState, action] = useFormState(formDataAction, {});

const [state, setState] = useState(serverState);

useEffect(() => {
console.log('Server state', serverState);
setState(serverState);
}, [serverState]);

useEffect(() => {
console.log('Form state', state);
if (state.error) {
toast.error(`Failed to add post: ${state.error.code}`);
}
}, [state]);

const form = useZodForm({
schema: addPostSchema,
defaultValues: serverState.input ?? {
title: 'Hello world',
},
});

useMemo(() => {
// hack to make errors work without javascript eanbled
for (const [key, value] of Object.entries(
serverState.error?.fieldErrors ?? {},
)) {
if (value) {
(form.formState.errors as any)[key] = {
type: 'server',
message: value.join(', '),
};
}
}
}, []);

return (
<FormProvider {...form}>
<form
action={action}
onSubmit={(event) => {
return form.handleSubmit(async (values) => {
const res = await formDataAction(
{},
new FormData(event.target as HTMLFormElement),
);
if (!res || res?.ok === undefined) {
// if you throw redirect or revalidatePath - you don't need to handle response
return;
}
setState(res);
if (res.ok) {
// happy
return;
}
if (res.error) {
switch (res.error.code) {
case 'INPUT_VALIDATION': {
// add validation errors
for (const [key, value] of Object.entries(
res.error.fieldErrors ?? {},
)) {
form.setError(key as any, {
type: 'server',
message: value.join(', '),
});
}
break;
}
default: {
form.setError('root', {
message:
'message' in res.error
? (res.error.message as string)
: res.error.code,
});
}
}
}
})(event);
}}
>
<div>
<input
type="text"
{...form.register('title')}
defaultValue={form.control._defaultValues.title}
/>
{form.formState.errors.title && (
<div>Invalid title: {form.formState.errors.title.message}</div>
)}
</div>

<div>
<input
type="text"
{...form.register('content')}
defaultValue={form.control._defaultValues.content}
/>
{form.formState.errors.content && (
<div>Invalid content: {form.formState.errors.content.message}</div>
)}
</div>
{/*
{state.error && (
<div>
<h3>Errors</h3>
{state.error?.code === 'UNAUTHORIZED' && (
<div>Unauthorized - you need to sign in</div>
)}
{state.error?.code === 'INPUT_VALIDATION' && (
<div>Invalid input</div>
)}
</div>
)} */}
<SubmitButton>Add post</SubmitButton>
</form>
</FormProvider>
);
}
@@ -0,0 +1,47 @@
'use client';

import { useState } from 'react';
import { simpleAddPost } from './_data';
import { SubmitButton } from './AddPostForm_useFormState';

export function AddPostForm_invoked() {
const [state, setState] =
useState<Awaited<ReturnType<typeof simpleAddPost> | null>>(null);

return (
<form
action={async (data) => {
const res = await simpleAddPost(data);
setState(res);
}}
>
<div>
<input name="title" defaultValue={state?.input?.title} />
{state?.error?.fieldErrors?.title && (
<div>Invalid title: {state?.error.fieldErrors.title.join(',')}</div>
)}
</div>
<div>
<input name="content" defaultValue={state?.input?.content} />
{state?.error?.fieldErrors?.content && (
<div>
Invalid content: {state?.error.fieldErrors.content.join(',')}
</div>
)}
</div>

{state?.error && (
<div>
<h3>Errors</h3>
{state?.error?.code === 'UNAUTHORIZED' && (
<div>Unauthorized - you need to sign in</div>
)}
{state?.error?.code === 'INPUT_VALIDATION' && (
<div>Invalid input</div>
)}
</div>
)}
<SubmitButton>Add post</SubmitButton>
</form>
);
}