Open11

ミニマム TanStack Router x TanStack/react-query AuthContext + CRUD 基本処理(Exampleコード)

kenmorikenmori

目的

  • TanStack Routerを使うことになったので素振り
  • TODOアプリ
  • 権限のオンオフでリアルタイムに遷移先が表示非表示されること(permissionsmanageTaskCreate)

気になったこと

  • 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;
}
kenmorikenmori

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

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()],
});
kenmorikenmori

src/types

import { QueryClient } from '@tanstack/react-query';
import { AuthContextType } from '../auth';

export interface RouterContext {
  queryClient: QueryClient;
  auth: AuthContextType;
}
kenmorikenmori

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

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

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

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

__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>
    </>
  );
}
kenmorikenmori

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

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