Closed11
ミニマム TanStack Router x TanStack/react-query AuthContext + CRUD 基本処理(Exampleコード)
目的
- TanStack Routerを使うことになったので素振り
- TODOアプリ
- 権限のオンオフでリアルタイムに遷移先が表示非表示されること(
permissions
のmanageTaskCreate
)
気になったこと
- mutationした後すぐに__root.tsを変更したい場合、
navigate({to: "/manage"})
で一度同じページを遷移させるとうまくいくがこれは正しい実装方法かどうか。 invalidateした後のcacheを参照するべきかどうか - TanStack RouterのドキュメントではAuthCotextを使うことを例としているが、それを使わないでloaderで都度認証情報を書くのは変?AuthContextは使った時と使わなかった時のメリデメ
src/auth.ts
import React from 'react';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
// https://tanstack.com/router/v1/docs/framework/react/examples/authenticated-routes-context
export type AuthContextType = {
isAuthenticated: boolean;
setUser: (user: User | null) => void;
user: User;
};
export type User = {
name: string;
permissions: {
manageTaskCreate: boolean;
manageTaskEdit: boolean;
manageTaskRemove: boolean;
};
} | null;
const AuthContext = React.createContext<AuthContextType | null>(null);
async function fetchPermissions() {
const data = await fetch(
'https://YOUR API HERE'
);
const json = await data.json();
return json;
}
export const permissionsQueryOptions = queryOptions({
queryKey: ['permissions'],
queryFn: () => fetchPermissions(),
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const permissionsQuery = useSuspenseQuery(permissionsQueryOptions);
const [user, setUser] = React.useState<User>({
name: '',
permissions: permissionsQuery.data[0],
});
const isAuthenticated = !!user?.name;
console.log('permissionsQuery.data[0]', permissionsQuery.data[0], user);
return (
<AuthContext.Provider value={{ isAuthenticated, user, setUser }}>
<div>{children}</div>
</AuthContext.Provider>
);
}
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
main.ts
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';
// Import the generated route tree
import { routeTree } from './routeTree.gen';
import { Route } from './routes/__root';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './auth';
export const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter<typeof routeTree>({
routeTree: routeTree.addChildren(Route.children),
context: {
queryClient,
permissions: {
manageTaskCreate: false,
manageTaskEdit: false,
manageTaskRemove: false,
},
auth: {
setUser: useAuth,
user: null,
isAuthenticated: false,
},
},
});
function InnerApp() {
const auth = useAuth();
return <RouterProvider router={router} context={{ auth }} />;
}
// Register the router instance for type safety
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById('app')!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<InnerApp />
</AuthProvider>
</QueryClientProvider>
</StrictMode>
);
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), TanStackRouterVite()],
});
src/types
import { QueryClient } from '@tanstack/react-query';
import { AuthContextType } from '../auth';
export interface RouterContext {
queryClient: QueryClient;
auth: AuthContextType;
}
utils.ts
import { redirect } from '@tanstack/react-router';
import { RouterContext } from '../types';
import { permissionsQueryOptions } from '../auth';
export async function beforeLoad({
context,
location,
}: {
context: RouterContext;
location: any;
}) {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
});
}
const data = await context.queryClient.ensureQueryData(
permissionsQueryOptions
);
context.auth.setUser({
name: context.auth.user?.name || '',
permissions: data[0], // TODO
});
}
src/routes/tasks.ts
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import {
Link,
Outlet,
createFileRoute,
MatchRoute,
} from '@tanstack/react-router';
import { beforeLoad } from '../utils/beforeLoad';
async function fetchTasks() {
const data = await fetch(
'YOUR TASK API'
);
const json = await data.json();
return json;
}
const tasksQueryOptions = queryOptions({
queryKey: ['tasks'],
queryFn: () => fetchTasks(),
});
export const Route = createFileRoute('/tasks')({
beforeLoad,
loader: ({ context: { queryClient, auth } }) => {
return queryClient.ensureQueryData(tasksQueryOptions);
},
component: TasksComponent,
});
function TasksComponent() {
const tasksQuery = useSuspenseQuery(tasksQueryOptions);
const tasks = tasksQuery.data;
return (
<div className="flex-1 flex">
<ul>
{tasks.map((e: any) => {
return (
<li key={e.id} className="whitespace-nowrap">
<Link
to="/tasks/$taskId"
params={{
taskId: e.id,
}}
preload="intent"
className="block py-2 px-3 text-blue-700"
activeProps={{ className: `font-bold` }}
>
<pre className="text-sm">
#{e.id} - {e.title}{' '}
<MatchRoute
to="/tasks/$taskId"
params={{
taskId: e.id,
}}
pending
></MatchRoute>
</pre>
</Link>
</li>
);
})}
</ul>
<hr />
<div className="flex-1 border-l border-gray-200">
<Outlet />
</div>
</div>
);
}
src/routes/manage.ts
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useAuth, User } from '../auth';
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../main';
import { beforeLoad } from '../utils/beforeLoad';
export const Route = createFileRoute('/manage')({
component: ManageComponent,
beforeLoad,
});
export async function putPermission(formData: Partial<User>) {
return fetch(
`YOUR API HERE`,
{
body: JSON.stringify(formData),
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
}
).then((r) => r.json() as Promise<User>);
}
export const useUpdatePermissionMutation = (navigate) => { // 違和感はある
return useMutation({
mutationKey: ['permission'],
mutationFn: putPermission,
onSuccess: async () => {
await queryClient.invalidateQueries(); // awaitしないと下が先に実行されるため
// ここでnaviagteすると__root.tsの該当箇所が表示非表示できる。これは正しいかどうか
navigate({ to: Route.to });
},
gcTime: 20,
});
};
function ManageComponent() {
const auth = useAuth();
const navigate = useNavigate({ from: Route.to });
const manageTaskCreate = auth.user?.permissions.manageTaskCreate;
const manageTaskEdit = auth.user?.permissions.manageTaskEdit;
const manageTaskRemove = auth.user?.permissions.manageTaskRemove;
const updatePermissionMutation = useUpdatePermissionMutation(navigate);
return (
<form
onSubmit={(event) => {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
updatePermissionMutation.mutate({
manageTaskCreate: !!formData.get('manageTaskCreate') as boolean,
manageTaskEdit: !!formData.get('manageTaskEdit') as boolan,
manageTaskRemove: !!formData.get('manageTaskRemove') as boolan,
});
}}
>
<label htmlFor="manageTaskCreate">manageTaskCreate</label>
<input
id="manageTaskCreate"
name="manageTaskCreate"
type="checkbox"
defaultChecked={manageTaskCreate || false}
/>
<br />
<label htmlFor="manageTaskEdit">manageTaskEdit</label>
<input
id="manageTaskEdit"
name="manageTaskEdit"
type="checkbox"
defaultChecked={manageTaskEdit || false}
/>
<br />
<label htmlFor="manageTaskRemove">manageTaskRemove</label>
<input
id="manageTaskRemove"
name="manageTaskRemove"
type="checkbox"
defaultChecked={manageTaskRemove || false}
/>
<div>
<button type="submit">更新</button>
</div>
</form>
);
}
src/routes/login.ts
import * as React from 'react';
import { flushSync } from 'react-dom';
import {
createFileRoute,
getRouteApi,
useNavigate,
} from '@tanstack/react-router';
import { z } from 'zod';
import { useAuth } from '../auth';
export const Route = createFileRoute('/login')({
validateSearch: z.object({
redirect: z.string().catch('/'),
}),
component: LoginComponent,
});
const routeApi = getRouteApi('/login');
function LoginComponent() {
const auth = useAuth();
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [name, setName] = React.useState('');
const search = routeApi.useSearch();
const handleLogin = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
setIsSubmitting(true);
flushSync(() => {
if (!auth.user?.permissions) return;
auth.setUser({ ...auth.user, name });
});
navigate({ to: search.redirect });
};
return (
<div className="p-2">
<h3>Login page</h3>
<form className="mt-4" onSubmit={handleLogin}>
<fieldset
disabled={isSubmitting}
className="flex flex-col gap-2 max-w-sm"
>
<div className="flex gap-2 items-center">
<label htmlFor="username-input" className="text-sm font-medium">
Username
</label>
<input
id="username-input"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="border border-gray-300 rounded-md p-2 w-full"
required
/>
</div>
<button
type="submit"
className="bg-blue-500 text-white py-2 px-4 rounded-md"
>
{isSubmitting ? 'Loading...' : 'Login'}
</button>
</fieldset>
</form>
</div>
);
}
__root.ts
import {
createRootRouteWithContext,
Link,
Outlet,
} from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
import { QueryClient } from '@tanstack/react-query';
import { useAuth, AuthContextType } from '/src/auth';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
export interface RouterContext {
queryClient: QueryClient;
auth: AuthContextType;
permissions: {
manageTaskCreate: boolean;
manageTaskEdit: boolean;
manageTaskRemove: boolean;
};
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});
// permissions:
function RootComponent() {
const auth = useAuth();
// 気になるポイント。更新された時に即時更新して欲しい箇所にcacheを使うのは避けるべきか積極的に使うべきか。AuthContextとの棲み分け
// const cache = queryClient.getQueryData(['permissions']);
return (
<>
<div style={{ border: '1px solid red' }}>
<div style={{ color: 'red' }}>root layout</div>
<div>
{auth.isAuthenticated ? (
<div>
<Link
to={'/tasks'}
activeProps={{
className: 'font-bold',
}}
>
Dashboard
</Link>
<div className="p-2 flex gap-2">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{' '}
{/* 該当箇所。{cache[0].manageTaskCreate ? (
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
) : null}{' '} */}
{auth.user?.permissions.manageTaskCreate ? (
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
) : null}{' '}
<Link to="/tasks" className="[&.active]:font-bold">
Tasks
</Link>{' '}
<Link to="/manage" className="[&.active]:font-bold">
Authority management
</Link>{' '}
</div>
</div>
) : (
<Link
to="/login"
activeProps={{
className: 'font-bold',
}}
search={{ redirect: '/' }}
>
Login
</Link>
)}
</div>
<hr />
<Outlet />
<ReactQueryDevtools buttonPosition="bottom-right" />
<TanStackRouterDevtools />
</div>
</>
);
}
src/tasks/index.ts
import { createFileRoute } from '@tanstack/react-router';
import { TaskFields } from '../../components/TaskFields';
import { Spinner } from '../../components/Spinner';
import { queryClient } from '../../main';
import { useMutation } from '@tanstack/react-query';
export const Route = createFileRoute('/tasks/')({
component: TaskIndexComponent,
});
type Task = { title: string; body: string; id: string };
export async function postTask(formData: Pick<Task, 'title' | 'body'>) {
return fetch(`YOURE API HRER/v1/todos/`, {
body: JSON.stringify(formData),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
}).then((r) => r.json() as Promise<Task>);
}
export const useCreateTaskMutation = () => {
return useMutation({
// mutationKey: ['invoices', 'create'],
mutationFn: postTask,
onSuccess: () => queryClient.invalidateQueries(),
});
};
function TaskIndexComponent() {
const createTaskMutation = useCreateTaskMutation();
return (
<div>
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
const formData = new FormData(event.target as HTMLFormElement);
createTaskMutation.mutate({
title: formData.get('title') as string,
body: formData.get('body') as string,
});
}}
className="space-y-2"
>
<div>Create a new Task:</div>
<TaskFields task={{} as Task} />
<div>
<button
className="bg-blue-500 rounded p-2 uppercase text-white font-black disabled:opacity-50"
disabled={createTaskMutation?.status === 'pending'}
>
{createTaskMutation?.status === 'pending' ? (
<>
Creating <Spinner />
</>
) : (
'Create'
)}
</button>
</div>
{createTaskMutation?.status === 'success' ? (
<div className="inline-block px-2 py-1 rounded bg-green-500 text-white animate-bounce [animation-iteration-count:2.5] [animation-duration:.3s]">
Created!
</div>
) : createTaskMutation?.status === 'error' ? (
<div className="inline-block px-2 py-1 rounded bg-red-500 text-white animate-bounce [animation-iteration-count:2.5] [animation-duration:.3s]">
Failed to create.
</div>
) : null}
</form>
</div>
);
}
src/tasks/$task.ts
import { useMutation } from '@tanstack/react-query';
import { TaskFields } from '../../components/TaskFields';
import { beforeLoad } from '../../utils/beforeLoad';
import { createFileRoute } from '@tanstack/react-router';
import { PickAsRequired } from '@tanstack/react-router';
import { queryClient } from '../../main';
export function Task() {
return <div>Hello /tasks/$taskId!</div>;
}
type Task = { title: string; body: string; id: string };
export const Route = createFileRoute('/tasks/$taskId')({
beforeLoad,
loader: async ({ params }) => {
console.log(`Fetching post with id ${params.taskId}...`);
await new Promise((r) => setTimeout(r, Math.round(Math.random() * 300)));
return fetch(
`YOURE API HERE/todos/${params.taskId}`
).then((r) => r.json() as Promise<Task>);
},
component: TaskComponent,
});
export async function putTask(formData: PickAsRequired<Partial<Task>, 'id'>) {
return fetch(
`YOURE API HRER/todos/${formData.id}`,
{
body: JSON.stringify(formData),
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
}
).then((r) => r.json() as Promise<Task>);
}
export const useUpdateTaskMutation = (taskId: string) => {
return useMutation({
mutationKey: ['tasks', 'update', taskId],
mutationFn: putTask,
onSuccess: () => queryClient.invalidateQueries(),
gcTime: 1000 * 10,
});
};
function TaskComponent() {
const task = Route.useLoaderData();
// const navigate = useNavigate();
const params = Route.useParams();
const updateTaskMutation = useUpdateTaskMutation(params.taskId);
return (
<div className="space-y-2">
<form
key={task.id}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
console.log('event.target', event.target);
const formData = new FormData(event.target as HTMLFormElement);
updateTaskMutation.mutate({
id: task.id,
title: formData.get('title') as string,
body: formData.get('body') as string,
});
}}
className="p-2 space-y-2"
>
<TaskFields task={task} />
<button
type="submit"
disabled={updateTaskMutation?.status === 'pending'}
style={{ marginLeft: 100 }}
>
更新
</button>
</form>
{updateTaskMutation?.variables?.id === task.id ? (
<div key={updateTaskMutation?.submittedAt}>
{updateTaskMutation?.status === 'success' ? (
<div className="inline-block px-2 py-1 rounded bg-green-500 text-white animate-bounce [animation-iteration-count:2.5] [animation-duration:.3s]">
Saved!
</div>
) : updateTaskMutation?.status === 'error' ? (
<div className="inline-block px-2 py-1 rounded bg-red-500 text-white animate-bounce [animation-iteration-count:2.5] [animation-duration:.3s]">
Failed to save.
</div>
) : null}
</div>
) : null}
</div>
);
}
このスクラップは2024/05/11にクローズされました