🤙

Remixでアプリケーションを作成

2024/05/05に公開

Remixの環境構築

こちらを参考にしたら、すんなりできました。
https://zenn.dev/akkey247/articles/20240417_remix_environment_construction_1

Mantine UIの設定

基本的には、こちらのフレームワークガイドに従って設定していきます。
https://mantine.dev/guides/remix/

APIのIPアドレス

今回は、バックエンドにDjango Ninjaを使用しており、開発環境にはDockerを使用しています。
この場合のIPアドレスはDockerのIPアドレスになります。

root.tsxの編集

まずはroot.tsxを編集します。
今回はログイン処理をRemix上で行うようにしますので、そのために必要なコードにしていきます。

root.tsv
frontend/frontend/app/root.tsx
import '@mantine/core/styles.css';
import { Link, Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData, useNavigate} from "@remix-run/react";
import { Box, Button, ColorSchemeScript, Container, Group, MantineProvider, PasswordInput, TextInput, Text } from '@mantine/core';
import { useEffect, useState } from 'react';
import classes from './HeaderMegaMenu.module.css'
import { useForm } from '@mantine/form';
import { LoaderFunctionArgs, json } from '@remix-run/node';
import { User } from './type';

interface FormValues {
  email: string
  password: string
}

export const loader = async ({ request }: LoaderFunctionArgs) => {
  // loginしているかどうかを確認する
  try {
    const cookies = request.headers.get('Cookie'); // Cookieを使用する場合
    const accessToken = cookies?.split(';').find(cookie => cookie.trim().startsWith("accessToken="))?.split('=')[1] // Cookieを使用する場合

    if (!accessToken) {
      return json({
        setIsLoggedIn: false
      })
    }

    // ログイン情報を取得(IPアドレスはDockerのIPアドレス)
    const res = await fetch(`${process.env.API_URL}/api/author/auth/get_login_user`, {
      method: 'GET',
      headers: {
        'accept': 'application/json',
        'Authorization': `Bearer ${accessToken}`
      }
    })

    if (res.ok) {
      const data = await res.json()
      return json({ userData: data })
    } else {
      return json({
        setIsLoggedIn: false
      })
    }
  } catch (error) {
    return error
  } 
}


export default function App({ children }: { children: React.ReactNode }) {
  const navigate = useNavigate()
  const { userData: userData } = useLoaderData<{ userData: User }>()
  const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false)
  const [error, setError] = useState<string | null>(null)
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    const cookies = document.cookie // Cookieを使用する場合
    const accessToken = cookies?.split(';').find(cookie => cookie.trim().startsWith("accessToken="))?.split('=')[1] // Cookieを使用する場合

    if (accessToken) {
      setIsLoggedIn(true)
      setUser(userData)
      navigate("/contacts")
    } else {
      setIsLoggedIn(false)
      navigate("/")
    }
  }, [navigate])


  const form = useForm<FormValues>({
    mode: 'uncontrolled',
    initialValues: {
      email: '',
      password: '',
    },
    validate: {
      email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
      password: (value) => (value.length < 1 ? "パスワードを入力してください" : null)
    }
  })

  const handleLogin = async (values: FormValues) => {
    try {
      const res = await fetch('http://localhost:8000/api/author/auth/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(values)
      })

      if (!res.ok) {
        throw new Error('Login failed.')
      }

      const data = await res.json()
      document.cookie = `accessToken=${data.access_token}; path=/`
      setIsLoggedIn(true)

      navigate('/contacts') // ログイン後の画面遷移
    } catch (error) {
      setError('Login failed')
    }
  }

  // logout
  const handleLogout = async () => {
    // cookieを使用する場合
    try {
      document.cookie = "accessToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"
      setIsLoggedIn(false)
      navigate("/")
    } catch (error) {
      setError('Logout failed.')
    }
  }
  

  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
        <ColorSchemeScript />
      </head>

      <body>
        <MantineProvider>
          <Container miw={700} h={200} mt={20}>
            <div className='item-center'>
              <header className={classes.header}>
                <div className='flex item-center'>
                  <Button component={Link} to="/contacts" mr={15} mb={15}>To contacts</Button>
                  <Button component={Link} to="/articles" mr={15} mb={15}>To articles</Button>
                  {isLoggedIn ?
                    <div className='inline-block mx-auto'>
                      <Text className='inline-block mx-auto'>{user?.username}さん</Text>
                      <Button component={Link} onClick={handleLogout} to="/auth" mr={15} mb={15}>LogOut</Button> {/* true(login中)のとき */}
                    </div> 
                    :
                    <Button component={Link} to="/auth" mr={15} mb={20}>LogIn</Button> // false(logout中)のとき
                  }
                </div>
              </header>
            </div>
            {children}
            {isLoggedIn ?
              <></>
              :
              <Box maw={340} mx="auto">
                <form onSubmit={form.onSubmit(handleLogin)}>
                  <TextInput
                    withAsterisk
                    label="メールアドレス"
                    placeholder="your@email.com"
                    mb={20}
                    {...form.getInputProps('email')}
                  />
                  <PasswordInput
                    placeholder="パスワード"
                    mb={20}
                    {...form.getInputProps('password')}
                  />
                  {error && <div style={{ color: 'red' }}>{error}</div>}
                  <Group justify="center" mt="md">
                    <Button type="submit">Login</Button>
                  </Group>
                </form>
              </Box>
            }

            <Outlet context={userData} /> {/* ここに作ったルーティングが配置される */}
          </Container>
        </MantineProvider>  
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

loader

ここでは、loader関数を使用して、ページ読み込み時にデータを取得するようにしています。
これができるのがloader関数です。

loader
export const loader = async ({ request }: LoaderFunctionArgs) => {
  // loginしているかどうかを確認する
  try {
    const cookies = request.headers.get('Cookie'); // Cookieを使用する場合
    const accessToken = cookies?.split(';').find(cookie => cookie.trim().startsWith("accessToken="))?.split('=')[1] // Cookieを使用する場合

    if (!accessToken) {
      return json({
        setIsLoggedIn: false
      })
    }

    // ログイン情報を取得(IPアドレスはDockerのIPアドレス)
    const res = await fetch(`${process.env.API_URL}/api/author/auth/get_login_user`, {
      method: 'GET',
      headers: {
        'accept': 'application/json',
        'Authorization': `Bearer ${accessToken}`
      }
    })

    if (res.ok) {
      const data = await res.json()
      return json({ userData: data })
    } else {
      return json({
        setIsLoggedIn: false
      })
    }
  } catch (error) {
    return error
  } 
}

今回は、ページ読み込み時にログインユーザーの情報を取得するものですが、Remixでは、このようにloader関数を追加することで、ページの読み込み時にデータの取得や準備を行うことができます。
loader関数はサーバーサイドで実行され、クライアントに必要なデータを提供します。
loader関数は、loader.tsファイル内に記述され、LoaderFunctionという特別な型を持ちます。

詳細はこちらを確認してください。
https://remix.run/docs/en/main/route/loader

useForm(Mantine UI)

レイアウトなどにはMantine UIを使用していますが、その中からuseFormを使用しています。

  const form = useForm<FormValues>({
    mode: 'uncontrolled',
    initialValues: {
      email: '',
      password: '',
    },
    validate: {
      email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
      password: (value) => (value.length < 1 ? "パスワードを入力してください" : null)
    }
  })

使い方などは、こちらのドキュメントを参照してください。
https://mantine.dev/form/use-form/

Outlet

Remixでは、Outletを使用することで、Outletを配置した部分に子ルートを配置することができます。
また、contextを設定することで、その配置された子ルートに値を渡すことができます。

<Outlet context={userData} />

今回は、contextとしてloaderから取得したログイン中のユーザーデータを格納したuserDataをcontextとして渡しています。

詳細はこちら。
https://remix.run/docs/en/main/components/outlet

https://remix.run/docs/en/main/hooks/use-navigate

article.tsx

atricles.tsx
frontend/frontend/app/routes/articles.tsx
import { LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData, useOutletContext } from "@remix-run/react";
import { Article } from "~/type";
import { Text } from '@mantine/core'

export const loader = async ({ request }: LoaderFunctionArgs) => {
  try {
    const res = await fetch(`${process.env.API_URL}/api/press/articles`, {
      method: 'GET',
      headers: {
        'accept': 'application/json',
      }
    })
    const articles = await res.json()
    return json({ articles })
  } catch (error) {
    return error
  }
}

export default function Articles() {
  const { articles } = useLoaderData<{ articles: Article }>()
  const userContext: User = useOutletContext()
  
  return (
    <>
      <h1>Article</h1>
      {context.articles}
      <div className="flex flex-col gap-4">
      <Text>{userContext?.username}さんの記事</Text>
        {articles ? (
          <ul>
            {articles.map((article: Article) => (
              <>
                <div className="flex flex-col gap-2">
                  <h2 key={article.id}>title: {article.title}</h2>
                  <p>No.{ article.id}</p>  
                </div>
                <p key={article.id}>author: {article.author.username}</p>
            </>
          ))}
          </ul>
        ) : (
            <i>No article</i>
        )}
      </div>
    </>
  )
}

routesディレクトリの直下にファイルを作成することで、このarticles.tsはhttp://localhost:3000/articlesを生成します。
また、root.tsxで取得したログイン中のユーザー情報をcontextで渡しています。
これを、useOutletContextで取得し、htmlに表示しています。

詳しくはこちらを参照してください。
https://remix.run/docs/en/main/hooks/use-outlet-context

所感

これまでNext.jsを使ったことはありましたが、web標準に準拠しているフレームワークなだけあって、フレームワーク独特の構文等が少なく、すんなり使えるイメージです。
最初は仕組みなどを理解する必要はありますが、将来的には他のフレームワークなどでも生かせるものが得られるフレームワークだと感じました。
今後は何かサイトやwebアプリなどにもトライしてみたいと思います。

Discussion